dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
package/lib/sync/merge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hashSection } from './sync-state.js';
2
+ import { mergeBytesThreeWay, mergeJsonValues } from './text-merge.js';
2
3
  /** 收集 local/remote/ancestor 三个快照中出现过的所有分区 id。 */
3
4
  function unionSectionIds(local, remote, ancestor) {
4
5
  const ids = new Set();
@@ -57,75 +58,71 @@ function deepEqual(a, b) {
57
58
  return false;
58
59
  return true;
59
60
  }
60
- /** JSON 分区精细合并:按 top-level key 走三方合并。
61
- * - 双侧均未改 → 跳过
62
- * - 仅一侧改 → 采纳该侧
63
- * - 双侧均改 → 若 deepEqual 自动合并;否则留为冲突 */
64
- function mergeJsonSectionGranular(local, remote, ancestor) {
65
- const merged = {};
66
- const conflicts = [];
67
- const keys = new Set([...Object.keys(local), ...Object.keys(remote), ...(ancestor ? Object.keys(ancestor) : [])]);
68
- for (const k of keys) {
69
- const lHas = Object.prototype.hasOwnProperty.call(local, k);
70
- const rHas = Object.prototype.hasOwnProperty.call(remote, k);
71
- const aHas = ancestor ? Object.prototype.hasOwnProperty.call(ancestor, k) : false;
72
- const lv = lHas ? local[k] : undefined;
73
- const rv = rHas ? remote[k] : undefined;
74
- const av = aHas ? ancestor[k] : undefined;
75
- if (lHas && rHas) {
76
- if (deepEqual(lv, rv)) {
77
- merged[k] = lv;
78
- continue;
79
- }
80
- // 双侧都有该 key 但值不同 → 看是否都改过
81
- const lChanged = !aHas || !deepEqual(lv, av);
82
- const rChanged = !aHas || !deepEqual(rv, av);
83
- if (lChanged && rChanged) {
84
- if (deepEqual(lv, rv)) {
85
- merged[k] = lv; // 两侧改后恰好相同
86
- }
87
- else {
88
- conflicts.push({ path: k, kind: 'key', local: lv, remote: rv, ancestor: av });
89
- }
90
- }
91
- else if (lChanged) {
92
- merged[k] = lv;
93
- }
94
- else if (rChanged) {
95
- merged[k] = rv;
96
- }
97
- else {
98
- merged[k] = lv; // 双侧都未相对祖先改但 deepEqual 已失败——保持一致
99
- }
100
- }
101
- else if (lHas && !rHas) {
102
- // 仅本地有:删/改/增?
103
- const lChanged = !aHas || !deepEqual(lv, av);
104
- if (lChanged) {
105
- // 本地增/改,远程删 → 冲突
106
- conflicts.push({ path: k, kind: 'key', local: lv, remote: undefined, ancestor: av });
107
- }
108
- else {
109
- // 本地等于祖先、远程缺:远程删 → 跟随删除
110
- }
111
- }
112
- else if (!lHas && rHas) {
113
- const rChanged = !aHas || !deepEqual(rv, av);
114
- if (rChanged) {
115
- // 本地删、远程增 → 冲突
116
- conflicts.push({ path: k, kind: 'key', local: undefined, remote: rv, ancestor: av });
117
- }
118
- else {
119
- // 远程等于祖先、本地缺:本地删 → 跟随删除
120
- }
121
- }
122
- else {
123
- // 仅祖先有:双侧都删 → 跟随删除
124
- }
61
+ function isPlainObject(v) {
62
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
63
+ }
64
+ /** 与 sync-state 的判定保持一致(鸭子类型):{ version: 1, files: [...] }。 */
65
+ function isFilesSectionData(data) {
66
+ if (data === null || typeof data !== 'object')
67
+ return false;
68
+ const obj = data;
69
+ return obj.version === 1 && Array.isArray(obj.files);
70
+ }
71
+ function bytesEqual(a, b) {
72
+ if (a === undefined || b === undefined)
73
+ return a === b;
74
+ if (a.length !== b.length)
75
+ return false;
76
+ for (let i = 0; i < a.length; i++)
77
+ if (a[i] !== b[i])
78
+ return false;
79
+ return true;
80
+ }
81
+ /** 文本/字节级合并产出的冲突成因 → 统一冲突成因。 */
82
+ function mapConflictReason(reason) {
83
+ switch (reason) {
84
+ case 'array': return 'array';
85
+ case 'format': return 'format';
86
+ case 'binary': return 'binary';
87
+ case 'too-large': return 'too-large';
88
+ default: return 'both-modified'; // 'overlap'(文本行重叠)= 双侧改同一处,统一口径
89
+ }
90
+ }
91
+ function fromTextConflicts(list) {
92
+ return list.map((c) => ({
93
+ path: c.path === '' ? '$' : c.path,
94
+ kind: 'key',
95
+ reason: mapConflictReason(c.reason),
96
+ autoMergeAttempted: true,
97
+ ...(c.local !== undefined ? { local: c.local } : {}),
98
+ ...(c.remote !== undefined ? { remote: c.remote } : {}),
99
+ ...(c.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
100
+ }));
101
+ }
102
+ /**
103
+ * 统计「自动合并并入了几个变更点」:叶子级比较,双侧值不同且合并结果取到一侧即计 1。
104
+ * 只统计对象叶子(数组按整体 1 个点),口径 = 用户能感知的「两边各自的改动都保住了」处数。
105
+ */
106
+ function countMergePoints(local, remote, merged) {
107
+ if (isPlainObject(local) && isPlainObject(remote) && isPlainObject(merged)) {
108
+ const keys = new Set([...Object.keys(local), ...Object.keys(remote), ...Object.keys(merged)]);
109
+ let n = 0;
110
+ for (const k of keys)
111
+ n += countMergePoints(local[k], remote[k], merged[k]);
112
+ return n;
113
+ }
114
+ if (Array.isArray(local) || Array.isArray(remote) || Array.isArray(merged)) {
115
+ return deepEqual(local, remote) ? 0 : 1;
125
116
  }
126
- return { merged, conflicts };
117
+ return deepEqual(local, remote) ? 0 : 1;
127
118
  }
128
- /** FilesSection 精细合并:按 relativePath 走三方合并(按文件字节 deepEqual)。 */
119
+ /**
120
+ * FilesSection 精细合并:按 relativePath 逐文件三方合并。
121
+ * - 双侧都改且内容不同 → 先尝试行级三方合并(文本/JSON);成功并入,失败留冲突(reason 如实);
122
+ * - 只有一侧改 → 采纳改动侧;
123
+ * - 一侧删、另一侧改/新增 → modify-delete / delete-modify 冲突(绝不直接删数据);
124
+ * - 一侧删、另一侧未改 → 跟随删除。
125
+ */
129
126
  function mergeFilesSectionGranular(local, remote, ancestor) {
130
127
  const lMap = new Map();
131
128
  for (const f of local.files)
@@ -140,84 +137,197 @@ function mergeFilesSectionGranular(local, remote, ancestor) {
140
137
  const paths = new Set([...lMap.keys(), ...rMap.keys(), ...aMap.keys()]);
141
138
  const mergedFiles = [];
142
139
  const conflicts = [];
143
- for (const p of paths) {
144
- const l = lMap.get(p);
145
- const r = rMap.get(p);
146
- const a = aMap.get(p);
147
- const eq = (x, y) => {
148
- if (x === undefined || y === undefined)
149
- return x === y;
150
- if (x.length !== y.length)
151
- return false;
152
- for (let i = 0; i < x.length; i++)
153
- if (x[i] !== y[i])
154
- return false;
155
- return true;
156
- };
157
- if (l && r) {
158
- if (eq(l, r)) {
159
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
140
+ let mergePoints = 0;
141
+ const push = (relativePath, data) => {
142
+ mergedFiles.push({ relativePath, data, contentHash: '' });
143
+ };
144
+ for (const path of [...paths].sort()) {
145
+ const l = lMap.get(path);
146
+ const r = rMap.get(path);
147
+ const a = aMap.get(path);
148
+ if (l !== undefined && r !== undefined) {
149
+ if (bytesEqual(l, r)) {
150
+ push(path, l);
160
151
  continue;
161
152
  }
162
- const lChanged = !a || !eq(l, a);
163
- const rChanged = !a || !eq(r, a);
153
+ const lChanged = a === undefined || !bytesEqual(l, a);
154
+ const rChanged = a === undefined || !bytesEqual(r, a);
164
155
  if (lChanged && rChanged) {
165
- conflicts.push({ path: p, kind: 'file', local: l, remote: r, ancestor: a });
166
- }
167
- else if (lChanged) {
168
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
156
+ const attempt = mergeBytesThreeWay(a, l, r);
157
+ if (attempt.ok && attempt.merged !== undefined) {
158
+ push(path, attempt.merged);
159
+ mergePoints += 1;
160
+ continue;
161
+ }
162
+ conflicts.push({
163
+ path,
164
+ kind: 'file',
165
+ reason: attempt.kind === 'binary' || attempt.kind === 'too-large'
166
+ ? (attempt.kind === 'binary' ? 'binary' : 'too-large')
167
+ : 'both-modified',
168
+ autoMergeAttempted: true,
169
+ local: l,
170
+ remote: r,
171
+ ancestor: a,
172
+ });
173
+ continue;
169
174
  }
170
- else if (rChanged) {
171
- mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
175
+ if (lChanged) {
176
+ push(path, l);
177
+ mergePoints += 1;
178
+ continue;
172
179
  }
173
- else {
174
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
180
+ if (rChanged) {
181
+ push(path, r);
182
+ mergePoints += 1;
183
+ continue;
175
184
  }
185
+ push(path, l);
186
+ continue;
176
187
  }
177
- else if (l && !r) {
188
+ if (l !== undefined && r === undefined) {
178
189
  const aExists = a !== undefined;
179
- const lChanged = aExists ? !eq(l, a) : true;
180
- if (lChanged) {
181
- if (!aExists) {
182
- // 纯本地新增(祖先里也没有)→ keepLocal
183
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
184
- }
185
- else {
186
- // 本地改/增 vs 远程删 → 冲突
187
- conflicts.push({ path: p, kind: 'file', local: l, remote: undefined, ancestor: a });
188
- }
189
- }
190
+ if (aExists && bytesEqual(l, a))
191
+ continue; // 本地未改、远端删 → 跟随删除
192
+ if (!aExists) {
193
+ push(path, l);
194
+ mergePoints += 1;
195
+ continue;
196
+ } // 纯本地新增
197
+ conflicts.push({ path, kind: 'file', reason: 'modify-delete', local: l, remote: undefined, ancestor: a });
198
+ continue;
190
199
  }
191
- else if (!l && r) {
200
+ if (l === undefined && r !== undefined) {
192
201
  const aExists = a !== undefined;
193
- const rChanged = aExists ? !eq(r, a) : true;
194
- if (rChanged) {
195
- if (!aExists) {
196
- // 纯远程新增(祖先里也没有)→ useRemote
197
- mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
198
- }
199
- else {
200
- // 本地删 vs 远程改/增 → 冲突
201
- conflicts.push({ path: p, kind: 'file', local: undefined, remote: r, ancestor: a });
202
- }
203
- }
202
+ if (aExists && bytesEqual(r, a))
203
+ continue; // 远端未改、本地删 → 跟随删除
204
+ if (!aExists) {
205
+ push(path, r);
206
+ mergePoints += 1;
207
+ continue;
208
+ } // 纯远端新增
209
+ conflicts.push({ path, kind: 'file', reason: 'delete-modify', local: undefined, remote: r, ancestor: a });
210
+ continue;
204
211
  }
212
+ // 双侧都删(或仅祖先有):跟随删除,不产生动作。
205
213
  }
206
- return { merged: { version: 1, files: mergedFiles }, conflicts };
214
+ return { merged: { version: 1, files: mergedFiles }, conflicts, mergePoints };
207
215
  }
208
- /** 识别 JSON 分区的「键级合并」键值映射(如 settings/ui 的 namespaces、providers 的 providers)。
209
- * 返回 undefined → 该分区无法做键级合并,调用方应退化为整分区冲突。 */
210
- function keyMapOf(data) {
211
- const obj = data;
212
- if (obj && typeof obj === 'object' && obj['version'] === 1) {
213
- const ns = obj['namespaces'];
214
- if (ns && typeof ns === 'object' && !Array.isArray(ns))
215
- return ns;
216
- const prov = obj['providers'];
217
- if (prov && typeof prov === 'object' && !Array.isArray(prov))
218
- return prov;
216
+ /** 单分区三方合并(决策链见文件头注释)。 */
217
+ function mergeSectionTriple(id, lData, rData, aData, h) {
218
+ // ⓪ 形状闸门(先于一切取舍):远端载荷来自不可信输入(解析层只保证整体是合法 JSON,
219
+ // 不保证每个分区的值形状正确)。值形状不对时**绝不原样采用** —— 那等于把畸形数据
220
+ // 写进本机配置;一律按 conflict(reason='format') 交用户裁决。
221
+ if (lData !== undefined && !isPlainObject(lData)) {
222
+ return {
223
+ id,
224
+ decision: 'conflict',
225
+ conflicts: [{ path: '$', kind: 'key', reason: 'format', autoMergeAttempted: false, local: lData, remote: rData, ancestor: aData }],
226
+ };
227
+ }
228
+ if (rData !== undefined && !isPlainObject(rData)) {
229
+ return {
230
+ id,
231
+ decision: 'conflict',
232
+ conflicts: [{ path: '$', kind: 'key', reason: 'format', autoMergeAttempted: false, local: lData, remote: rData, ancestor: aData }],
233
+ };
234
+ }
235
+ // ① 远端无该分区:本地未改 → 无变化;本地改了 → 冲突(不准把本地改动当垃圾丢掉)
236
+ if (rData === undefined && lData !== undefined) {
237
+ const lChanged = h.ancestor === undefined || h.local !== h.ancestor;
238
+ if (!lChanged)
239
+ return { id, decision: 'skip', conflicts: [] };
240
+ return {
241
+ id,
242
+ decision: 'conflict',
243
+ conflicts: [{
244
+ path: '$',
245
+ kind: 'key',
246
+ reason: h.ancestor === undefined ? 'no-ancestor' : 'modify-delete',
247
+ autoMergeAttempted: false,
248
+ local: lData,
249
+ remote: undefined,
250
+ ancestor: aData,
251
+ }],
252
+ };
253
+ }
254
+ // ② 本地无该分区、远端有:远端未改 → 无变化;远端改了 → 采用远端(分区缺失不是删除语义,见文件头)
255
+ if (lData === undefined && rData !== undefined) {
256
+ const rChanged = h.ancestor === undefined || h.remote !== h.ancestor;
257
+ if (!rChanged)
258
+ return { id, decision: 'skip', conflicts: [] };
259
+ return { id, decision: 'useRemote', merged: rData, conflicts: [] };
219
260
  }
220
- return undefined;
261
+ if (lData === undefined && rData === undefined)
262
+ return { id, decision: 'skip', conflicts: [] };
263
+ const local = lData;
264
+ const remote = rData;
265
+ if (h.local === h.remote)
266
+ return { id, decision: 'skip', conflicts: [] };
267
+ // 分区形态互换(一侧 FilesSection、一侧 JSON 分区)→ 早于 useRemote/keepLocal 判冲突:
268
+ // 「远端改了、本地没改」不足以支撑把另一种形态的数据整分区写进本机(语义不同,采用必错)。
269
+ if (isFilesSectionData(local) !== isFilesSectionData(remote)) {
270
+ return {
271
+ id,
272
+ decision: 'conflict',
273
+ conflicts: [{ path: '$', kind: 'key', reason: 'section', autoMergeAttempted: false, local, remote, ancestor: aData }],
274
+ };
275
+ }
276
+ if (h.ancestor === undefined) {
277
+ return {
278
+ id,
279
+ decision: 'conflict',
280
+ conflicts: [{ path: '$', kind: 'key', reason: 'no-ancestor', autoMergeAttempted: false, local, remote, ancestor: undefined }],
281
+ };
282
+ }
283
+ if (h.local === h.ancestor)
284
+ return { id, decision: 'useRemote', merged: remote, conflicts: [] };
285
+ if (h.remote === h.ancestor)
286
+ return { id, decision: 'keepLocal', merged: local, conflicts: [] };
287
+ // ③ 双侧相对祖先都改 → 精细合并(形状与形态闸门已在 ⓪/前段通过)
288
+ if (isFilesSectionData(local) && isFilesSectionData(remote)) {
289
+ const aFiles = aData !== undefined && isFilesSectionData(aData) ? aData : undefined;
290
+ const { merged, conflicts, mergePoints } = mergeFilesSectionGranular(local, remote, aFiles);
291
+ if (conflicts.length === 0 && mergePoints > 0) {
292
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: mergePoints };
293
+ }
294
+ if (conflicts.length === 0) {
295
+ // 双侧都改但合并结果与某一侧逐字相同 → 如实报是哪一侧赢了(不谎称「合并」)
296
+ const mh = hashSection(merged);
297
+ if (mh === h.remote)
298
+ return { id, decision: 'useRemote', merged, conflicts: [] };
299
+ if (mh === h.local)
300
+ return { id, decision: 'keepLocal', merged, conflicts: [] };
301
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: 0 };
302
+ }
303
+ return { id, decision: 'conflict', conflicts };
304
+ }
305
+ if (isPlainObject(local) && isPlainObject(remote)) {
306
+ const aJson = aData !== undefined && isPlainObject(aData) ? aData : undefined;
307
+ const res = mergeJsonValues(aJson, local, remote);
308
+ if (!res.ok || res.merged === undefined) {
309
+ const conflicts = res.conflicts.length > 0
310
+ ? fromTextConflicts(res.conflicts)
311
+ : [{ path: '$', kind: 'key', reason: 'format', autoMergeAttempted: true, local, remote, ancestor: aData }];
312
+ return { id, decision: 'conflict', conflicts };
313
+ }
314
+ const merged = res.merged;
315
+ if (res.conflicts.length === 0) {
316
+ const mh = hashSection(merged);
317
+ if (mh === h.remote)
318
+ return { id, decision: 'useRemote', merged, conflicts: [] };
319
+ if (mh === h.local)
320
+ return { id, decision: 'keepLocal', merged, conflicts: [] };
321
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: countMergePoints(local, remote, merged) };
322
+ }
323
+ return { id, decision: 'conflict', conflicts: fromTextConflicts(res.conflicts) };
324
+ }
325
+ // 类型不一致(一侧 FilesSection、一侧 JSON)或非对象 → 整分区冲突,交用户裁决
326
+ return {
327
+ id,
328
+ decision: 'conflict',
329
+ conflicts: [{ path: '$', kind: 'key', reason: 'section', autoMergeAttempted: false, local, remote, ancestor: aData }],
330
+ };
221
331
  }
222
332
  /** 主入口:三方合并。纯函数,无副作用。 */
223
333
  export function merge(local, remote, ancestor) {
@@ -228,98 +338,8 @@ export function merge(local, remote, ancestor) {
228
338
  const hashes = computeHashes(l, r, a, ids);
229
339
  const results = [];
230
340
  for (const id of ids) {
231
- const h = hashes.get(id);
232
- const lData = l[id];
233
- const rData = r[id];
234
- const aData = a ? a[id] : undefined;
235
- // 远端无 → skip(不在本轮考虑;本地有但远端没有 = 远端"删了",视作 useRemote=删除)
236
- // 本实现按远端视角:远端缺 + 本地存在 + 本地未改祖先 → skip(无变化);否则冲突。
237
- if (rData === undefined && lData !== undefined) {
238
- const lChanged = h.ancestor === undefined || h.local !== h.ancestor;
239
- if (!lChanged) {
240
- results.push({ id, decision: 'skip', conflicts: [] });
241
- continue;
242
- }
243
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: undefined, ancestor: aData }] });
244
- continue;
245
- }
246
- // 本地无、远端有:远端新增;本地删除过(=等于祖先空)+祖先空→skip;否则按 useRemote。
247
- if (lData === undefined && rData !== undefined) {
248
- const rChanged = h.ancestor === undefined || h.remote !== h.ancestor;
249
- if (!rChanged) {
250
- results.push({ id, decision: 'skip', conflicts: [] });
251
- continue;
252
- }
253
- results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
254
- continue;
255
- }
256
- // 双方都没有 → skip
257
- if (lData === undefined && rData === undefined) {
258
- results.push({ id, decision: 'skip', conflicts: [] });
259
- continue;
260
- }
261
- // 双方都有
262
- if (h.local === h.remote) {
263
- results.push({ id, decision: 'skip', conflicts: [] });
264
- continue;
265
- }
266
- if (h.ancestor === undefined) {
267
- // 无祖先 → 两方差异视为整分区冲突(不猜测)
268
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: undefined }] });
269
- continue;
270
- }
271
- if (h.local === h.ancestor) {
272
- results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
273
- continue;
274
- }
275
- if (h.remote === h.ancestor) {
276
- results.push({ id, decision: 'keepLocal', merged: lData, conflicts: [] });
277
- continue;
278
- }
279
- // 双侧相对祖先都改 → 进入精细合并
280
- if (isFilesSectionData(lData)) {
281
- const { merged, conflicts } = mergeFilesSectionGranular(lData, rData, aData);
282
- if (conflicts.length === 0) {
283
- results.push({ id, decision: 'useRemote', merged: merged, conflicts: [] });
284
- }
285
- else {
286
- results.push({ id, decision: 'conflict', conflicts, merged: undefined });
287
- }
288
- continue;
289
- }
290
- // JSON 分区:识别可键级合并的子映射(namespaces / providers)
291
- const lKeyMap = keyMapOf(lData);
292
- const rKeyMap = keyMapOf(rData);
293
- const aKeyMap = aData ? keyMapOf(aData) : undefined;
294
- if (lKeyMap && rKeyMap) {
295
- const { merged: mergedKeys, conflicts } = mergeJsonSectionGranular(lKeyMap, rKeyMap, aKeyMap);
296
- // 重组 SectionData:用 l/r 中较新者保留非键级合并字段(version 等),键级合并字段用 mergedKeys
297
- const lObj = lData;
298
- const rObj = rData;
299
- const base = { ...lObj, ...rObj };
300
- // 找到键级合并字段在 base 中的键名
301
- const keyFieldName = lKeyMap === lObj['namespaces'] ? 'namespaces'
302
- : lKeyMap === lObj['providers'] ? 'providers' : undefined;
303
- if (keyFieldName)
304
- base[keyFieldName] = mergedKeys;
305
- if (conflicts.length === 0) {
306
- results.push({ id, decision: 'useRemote', merged: base, conflicts: [] });
307
- }
308
- else {
309
- results.push({ id, decision: 'conflict', conflicts, merged: undefined });
310
- }
311
- continue;
312
- }
313
- // 非键级合并 JSON 分区(plugins/mcp/prompts/workspaces 等):退化为整分区冲突
314
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: aData }] });
341
+ results.push(mergeSectionTriple(id, l[id], r[id], a ? a[id] : undefined, hashes.get(id)));
315
342
  }
316
343
  return { sections: results };
317
344
  }
318
- /** 分区是否为文件类(FilesSection)—— 鸭子类型,复用 sync-state 的判定以保持一致。 */
319
- function isFilesSectionData(data) {
320
- if (data === null || typeof data !== 'object')
321
- return false;
322
- const obj = data;
323
- return obj.version === 1 && Array.isArray(obj.files);
324
- }
325
345
  //# sourceMappingURL=merge.js.map
@@ -58,6 +58,14 @@ export declare const zh: {
58
58
  readonly 'sync.backupHistorySkipped': '{trigger}跳过:{reason}';
59
59
  readonly 'sync.backupHistoryFailed': '{trigger}失败';
60
60
  readonly 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示';
61
+ readonly 'sync.probeUnsupported': '当前同步通道不支持连通性自检';
62
+ readonly 'sync.webdav.concurrentWrite': '远端索引已被其它设备更新(HTTP 412):本次写入已中止,请重新同步后再试';
63
+ readonly 'sync.webdav.probeOk': '连接正常(远端 {count} 份快照)';
64
+ readonly 'sync.webdav.probeUnreachable': 'WebDAV 目录不可访问(HTTP {status}):{url}';
65
+ readonly 'sync.webdav.collectionCreated': '已自动创建远端目录:{path}';
66
+ readonly 'sync.webdav.collectionCreateFailed': '创建远端目录失败(HTTP {status}):{path}';
67
+ readonly 'sync.deviceNameFallback': '未命名设备';
68
+ readonly 'sync.git.probeOk': '连接正常(远端可达、凭据可用)';
61
69
  };
62
70
  export declare const en: Record<keyof typeof zh, string>;
63
71
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -85,6 +85,17 @@ export const zh = {
85
85
  'sync.backupHistoryFailed': '{trigger}失败',
86
86
  // ---------- 推送前只读预览(issue #91) ----------
87
87
  'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
88
+ // ---------- cloud-sync 借鉴项 ①②③:条件写 / 探针 / 首连自建目录 ----------
89
+ 'sync.probeUnsupported': '当前同步通道不支持连通性自检',
90
+ 'sync.webdav.concurrentWrite': '远端索引已被其它设备更新(HTTP 412):本次写入已中止,请重新同步后再试',
91
+ 'sync.webdav.probeOk': '连接正常(远端 {count} 份快照)',
92
+ 'sync.webdav.probeUnreachable': 'WebDAV 目录不可访问(HTTP {status}):{url}',
93
+ 'sync.webdav.collectionCreated': '已自动创建远端目录:{path}',
94
+ 'sync.webdav.collectionCreateFailed': '创建远端目录失败(HTTP {status}):{path}',
95
+ // ---------- cloud-sync 借鉴项 ⑥:设备身份 ----------
96
+ 'sync.deviceNameFallback': '未命名设备',
97
+ // ---------- git 通道连通性探针(cloud-sync 借鉴项 ②,补齐四通道) ----------
98
+ 'sync.git.probeOk': '连接正常(远端可达、凭据可用)',
88
99
  };
89
100
  export const en = {
90
101
  'sync.ancestorSnapshotIdRequired': 'loadAncestor: snapshotId must not be empty',
@@ -145,6 +156,14 @@ export const en = {
145
156
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
146
157
  'sync.backupHistoryFailed': '{trigger} failed',
147
158
  'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
159
+ 'sync.probeUnsupported': 'This sync channel does not support a connectivity self-check',
160
+ 'sync.webdav.concurrentWrite': 'The remote index was updated by another device (HTTP 412): this write was aborted, please sync again',
161
+ 'sync.webdav.probeOk': 'Connection OK ({count} remote snapshots)',
162
+ 'sync.webdav.probeUnreachable': 'The WebDAV directory is unreachable (HTTP {status}): {url}',
163
+ 'sync.webdav.collectionCreated': 'Created the remote directory automatically: {path}',
164
+ 'sync.webdav.collectionCreateFailed': 'Failed to create the remote directory (HTTP {status}): {path}',
165
+ 'sync.deviceNameFallback': 'Unnamed device',
166
+ 'sync.git.probeOk': 'Connection OK (remote reachable, credentials accepted)',
148
167
  };
149
168
  registerMessageCatalog({ id: 'sync-domain', zh, en });
150
169
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -9,8 +9,16 @@
9
9
  * 本模块只做分流,`SECTION_RISK_TIER` 是注册表的派生视图(保留给既有调用方);
10
10
  * - 双向 conflict 分区永远进 review(无论风险与 firstSync 与否);
11
11
  * - firstSync=true 时所有非 skip 项一律进 review(安全第一:人工确认后才开启自动);
12
- * - firstSync=false 时按风险等级分流:低风险且 useRemote/keepLocal 进 autoApply;
13
- * 中风险、high、双向冲突 → review;skip 或无变化 → skipped。
12
+ * - firstSync=false 时按风险等级分流:决策明确(useRemote / keepLocal / **merge** 即三方精细
13
+ 合并成功)且「风险等级 ≤ maxAutoTier」的分区进 autoApply;超过阈值的、high、双向冲突 → review;
14
+ skip 或无变化 → skipped。
15
+ * - 'merge' 与 useRemote/keepLocal 同等对待:它的 merged 是双方改动的合并结果(不是单侧原值)。
16
+ *
17
+ * maxAutoTier(真·自动同步,2026-10):
18
+ * - 缺省 'low' = 改造前的口径(只有低风险分区自动落盘),既有调用方(预览/UI)行为不变;
19
+ * - 自动同步按用户配置传 'medium' ⇒ plugins / mcp 的中风险改动也自动落盘。
20
+ * 这是用户拍板的产品决策:不改这条,「同步所有更改」就只是句口号(中风险分区永远停在待确认)。
21
+ * - **high 永远不参与自动应用**:类型上就只允许 low|medium,杜绝「传 high 顺手放行全部」。
14
22
  */
15
23
  import type { MergePlan, MergeSectionResult } from './merge.ts';
16
24
  import type { SectionId } from '../schema/types.ts';
@@ -40,7 +48,16 @@ export interface ClassifyOptions {
40
48
  * 调用方在 SyncEngine 中维护 firstSyncCompleted 标志。
41
49
  */
42
50
  firstSync: boolean;
51
+ /**
52
+ * 允许自动落盘的最高风险等级(缺省 'low' = 改造前的行为)。
53
+ *
54
+ * 只有自动同步路径会传 'medium'(用户在配置里显式选的,缺省也是 medium);
55
+ * high 在类型层面就被排除 —— 那不是「配置项」,是安全边界。
56
+ */
57
+ maxAutoTier?: 'low' | 'medium';
43
58
  }
59
+ /** 该风险等级是否在允许自动落盘的阈值之内。 */
60
+ export declare function tierWithinAutoApply(tier: RiskTier, maxAutoTier: 'low' | 'medium'): boolean;
44
61
  /** 自动应用三组结果(互斥:同一 sectionId 只出现在一组) */
45
62
  export interface SyncApplyPlan {
46
63
  autoApply: MergeSectionResult[];
package/lib/sync/risk.js CHANGED
@@ -15,6 +15,12 @@ export const SECTION_RISK_TIER = Object.fromEntries(SECTION_IDS.map((id) => [id,
15
15
  export function riskTierOf(id) {
16
16
  return sectionMetaOf(id)?.riskTier;
17
17
  }
18
+ /** 风险等级序(用于「等级 ≤ 阈值」比较;high 永不满足自动应用,因为阈值上限就是 medium)。 */
19
+ const TIER_RANK = { low: 0, medium: 1, high: 2 };
20
+ /** 该风险等级是否在允许自动落盘的阈值之内。 */
21
+ export function tierWithinAutoApply(tier, maxAutoTier) {
22
+ return TIER_RANK[tier] <= TIER_RANK[maxAutoTier];
23
+ }
18
24
  /**
19
25
  * 按风险等级 + firstSync 标志将 MergePlan 分流到三组。
20
26
  * 纯函数:相同输入 → 相同输出;不读 fs、不发网络。
@@ -23,6 +29,7 @@ export function classifyMergePlan(plan, opts) {
23
29
  const autoApply = [];
24
30
  const review = [];
25
31
  const skipped = [];
32
+ const maxAutoTier = opts.maxAutoTier ?? 'low';
26
33
  for (const r of plan.sections) {
27
34
  // 跳过:决策为 skip(远端缺且本地未改 / 完全无变化)
28
35
  if (r.decision === 'skip') {
@@ -45,12 +52,12 @@ export function classifyMergePlan(plan, opts) {
45
52
  review.push(r);
46
53
  continue;
47
54
  }
48
- if (tier === 'low') {
49
- // 低风险且决策明确(useRemote / keepLocal)→ 自动应用
55
+ if (tierWithinAutoApply(tier, maxAutoTier)) {
56
+ // 决策明确(useRemote / keepLocal / merge)且等级在阈值内 → 自动应用
50
57
  autoApply.push(r);
51
58
  }
52
59
  else {
53
- // medium / high → 待审
60
+ // 超过阈值(缺省阈值下即 medium / high)→ 待审
54
61
  review.push(r);
55
62
  }
56
63
  }