dsh-config-manager 0.1.78 → 0.1.79

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 (290) 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 +400 -112
  26. package/lib/client.js +2845 -1524
  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/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -0,0 +1,350 @@
1
+ /**
2
+ * 会话**活跃时间窗**台账 —— 「某个时刻有哪些会话正在活着」。
3
+ *
4
+ * 为什么需要(2026-10-17 用户反馈 + 真机复盘):写入归属(`rollback-attribution.ts`)只能从
5
+ * `tool/call` 的 arguments 里抽 `file_path`;`pwsh` / `run_code` / 脚本 / 外部编辑器写文件时
6
+ * **没有可解析的路径参数** ⇒ 这些写入完全没有归属,回退时识别不出「别的对话刚写过」,
7
+ * 于是把它一起抹掉(真机:B 对话给 README 加了一行,回退 A 时那一行也没了)。
8
+ *
9
+ * 这里补的是**时间维度**:文件 mtime 若落在某个会话的活跃窗口内,该会话就是候选写入者 ——
10
+ * 与工具参数完全无关,对 bash / 脚本 / 外部编辑器同样有效。
11
+ *
12
+ * ⚠️ 边界(不得越界解读):时间窗只能证明「该会话在那时候有活动」,**不能单独证明「就是它写的」**。
13
+ * 它是旁证,不是判决;引擎必须把它与归属台账合起来用(见 rollback-point.ts 的判定)。
14
+ *
15
+ * 三条不变量(不得放宽):
16
+ * ① 采集**尽力而为**:只记「会话 id + 时间」,任何读写失败只 warn,绝不参与留点 / 回退的成败判定。
17
+ * ② 窗口只增不减:同一会话的多次活动按 gapMs 合并;落盘前先读旧文件做**并集**,
18
+ * 两个 DSH 进程同写一个文件时绝不互相抹掉(落盘纪律照抄 `rollback-attribution.ts`)。
19
+ * ③ 形状不符 = 空表:读不出来时宁可少一个窗口,也绝不猜字段含义(猜错会让回退误判归属)。
20
+ */
21
+ import fs from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ /** 台账文件名(与回滚点库同目录:dataDir/rollback-points/activity.json) */
24
+ export const ROLLBACK_ACTIVITY_FILE_NAME = 'activity.json';
25
+ /** 同一会话两条活动的间隔 <= 该值就并成一个窗口(缺省 10 分钟) */
26
+ export const ROLLBACK_ACTIVITY_DEFAULT_GAP_MS = 10 * 60 * 1000;
27
+ /** 窗口条目上限(超出按 to 淘汰最旧)—— 无界增长会把宿主拖垮(见 issue #89 的教训) */
28
+ export const ROLLBACK_ACTIVITY_MAX_INTERVALS = 20000;
29
+ /** 读一条窗口;形状不符返回 null(跳过,不猜) */
30
+ function readInterval(value) {
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
32
+ return null;
33
+ const record = value;
34
+ const sessionId = record.sessionId;
35
+ const from = record.from;
36
+ const to = record.to;
37
+ if (typeof sessionId !== 'string' || sessionId === '')
38
+ return null;
39
+ if (typeof from !== 'number' || !Number.isFinite(from) || from < 0)
40
+ return null;
41
+ if (typeof to !== 'number' || !Number.isFinite(to) || to < 0)
42
+ return null;
43
+ if (to < from)
44
+ return null;
45
+ return { sessionId, from, to };
46
+ }
47
+ /**
48
+ * 把一条窗口 [from, to] 并进窗口表(**纯函数**;落盘的「写前先读」也复用它)。
49
+ *
50
+ * 合并口径:与已有窗口的距离 <= gapMs 就并成一条(含重叠、相接);
51
+ * 表内窗口按 from 升序且互不相接,所以「向后找不到可并的」即可停 —— 更早的窗口 to 只会更小。
52
+ */
53
+ function mergeRangeInto(table, gapMs, sessionId, from, to) {
54
+ let list = table.get(sessionId);
55
+ if (list === undefined) {
56
+ list = [];
57
+ table.set(sessionId, list);
58
+ }
59
+ let hit = -1;
60
+ for (let i = list.length - 1; i >= 0; i -= 1) {
61
+ const iv = list[i];
62
+ if (iv === undefined)
63
+ continue;
64
+ if (from <= iv.to + gapMs && to >= iv.from - gapMs) {
65
+ hit = i;
66
+ break;
67
+ }
68
+ if (from > iv.to + gapMs)
69
+ break;
70
+ }
71
+ if (hit === -1) {
72
+ // 新开一条,按 from 升序插入(时钟回拨 / 乱序事件也不能让表失序)
73
+ let pos = list.length;
74
+ while (pos > 0) {
75
+ const prev = list[pos - 1];
76
+ if (prev === undefined || prev.from <= from)
77
+ break;
78
+ pos -= 1;
79
+ }
80
+ list.splice(pos, 0, { sessionId, from, to });
81
+ return;
82
+ }
83
+ const target = list[hit];
84
+ if (target === undefined)
85
+ return;
86
+ target.from = Math.min(target.from, from);
87
+ target.to = Math.max(target.to, to);
88
+ // 扩张后可能把左右邻居也拉进 gapMs 内 ⇒ 一并吸收,维持「互不相接」不变量
89
+ let index = hit;
90
+ while (index > 0) {
91
+ const left = list[index - 1];
92
+ const right = list[index];
93
+ if (left === undefined || right === undefined || right.from - left.to > gapMs)
94
+ break;
95
+ left.from = Math.min(left.from, right.from);
96
+ left.to = Math.max(left.to, right.to);
97
+ list.splice(index, 1);
98
+ index -= 1;
99
+ }
100
+ while (index + 1 < list.length) {
101
+ const left = list[index];
102
+ const right = list[index + 1];
103
+ if (left === undefined || right === undefined || right.from - left.to > gapMs)
104
+ break;
105
+ left.to = Math.max(left.to, right.to);
106
+ list.splice(index + 1, 1);
107
+ }
108
+ }
109
+ /** 窗口表 → 有稳定顺序的数组(先按会话 id,再按 from/to;落盘内容不随 Map 顺序抖动) */
110
+ function flattenIntervals(table) {
111
+ const out = [];
112
+ for (const list of table.values()) {
113
+ for (const iv of list)
114
+ out.push({ sessionId: iv.sessionId, from: iv.from, to: iv.to });
115
+ }
116
+ out.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.from - b.from || a.to - b.to));
117
+ return out;
118
+ }
119
+ /**
120
+ * 内存窗口表 + best-effort 落盘。
121
+ *
122
+ * 落盘的作用只有一个:**进程重启后**给旧会话回退时,活跃窗口还在(否则新进程只有本次启动后看到的窗口)。
123
+ * 因此写入是延迟合并的(一次会话事件一行,立即落盘会把 I/O 打成筛子)。
124
+ */
125
+ export class RollbackSessionActivity {
126
+ table = new Map();
127
+ file;
128
+ now;
129
+ gapMs;
130
+ maxIntervals;
131
+ flushDelayMs;
132
+ warn;
133
+ loadPromise = null;
134
+ flushTimer = null;
135
+ writing = null;
136
+ /** 内存变更计数:与 lastFlushedRevision 相等 ⇒ 没有未落盘数据(flush 幂等靠它,不靠时间戳) */
137
+ revision = 0;
138
+ lastFlushedRevision = 0;
139
+ constructor(options) {
140
+ this.file = path.resolve(options.file);
141
+ this.now = options.now ?? (() => Date.now());
142
+ const gap = options.gapMs ?? ROLLBACK_ACTIVITY_DEFAULT_GAP_MS;
143
+ this.gapMs = Number.isFinite(gap) && gap >= 0 ? gap : ROLLBACK_ACTIVITY_DEFAULT_GAP_MS;
144
+ const max = options.maxIntervals ?? ROLLBACK_ACTIVITY_MAX_INTERVALS;
145
+ this.maxIntervals = Number.isFinite(max) && max >= 1 ? Math.floor(max) : ROLLBACK_ACTIVITY_MAX_INTERVALS;
146
+ const delay = options.flushDelayMs ?? 1000;
147
+ this.flushDelayMs = Number.isFinite(delay) ? delay : 1000;
148
+ this.warn = options.warn ?? (() => { });
149
+ }
150
+ /** 窗口总条数(诊断与测试用;上限见 maxIntervals。等价于「条目」,不是会话数) */
151
+ get size() {
152
+ let total = 0;
153
+ for (const list of this.table.values())
154
+ total += list.length;
155
+ return total;
156
+ }
157
+ /** 惰性读盘(幂等;读失败 = 空表,只 warn) */
158
+ async load() {
159
+ if (this.loadPromise === null)
160
+ this.loadPromise = this.read();
161
+ return this.loadPromise;
162
+ }
163
+ async read() {
164
+ try {
165
+ const text = await fs.readFile(this.file, 'utf8');
166
+ const parsed = JSON.parse(text);
167
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.version !== 1 || !Array.isArray(parsed.intervals)) {
168
+ this.warn('rollback activity ignored (shape): ' + this.file);
169
+ return;
170
+ }
171
+ for (const raw of parsed.intervals) {
172
+ const iv = readInterval(raw);
173
+ if (iv === null)
174
+ continue;
175
+ // 磁盘只是旧快照:**并集合并** ⇒ 读盘不得让本进程刚记的窗口消失(新记录优先)
176
+ mergeRangeInto(this.table, this.gapMs, iv.sessionId, iv.from, iv.to);
177
+ }
178
+ if (this.size > this.maxIntervals)
179
+ this.evictOldest();
180
+ }
181
+ catch (error) {
182
+ const code = error.code;
183
+ // ENOENT = 还没写过台账(正常);其它错误如实留痕但不抛
184
+ if (code !== 'ENOENT')
185
+ this.warn('rollback activity unreadable: ' + this.file + ' ' + String(code ?? error));
186
+ }
187
+ }
188
+ /**
189
+ * 记一次活动。窗口只增不减:与上一条间隔 <= gapMs 就并入同一条窗口,否则新开一条。
190
+ *
191
+ * `at` 缺省取注入时钟(`options.now`):引擎把**事件时间**显式传进来时以它为准
192
+ * (事件时间才是那次活动的真实时刻,取数时刻可能晚很多)。
193
+ * 空会话 id、非有限数字、负数一律**忽略**(fail-closed:宁可少一个窗口,绝不记错归属)。
194
+ */
195
+ noteActivity(sessionId, at = this.clockNow()) {
196
+ if (typeof sessionId !== 'string' || sessionId.trim() === '')
197
+ return;
198
+ if (typeof at !== 'number' || !Number.isFinite(at) || at < 0)
199
+ return;
200
+ mergeRangeInto(this.table, this.gapMs, sessionId, at, at);
201
+ this.revision += 1;
202
+ if (this.size > this.maxIntervals)
203
+ this.evictOldest();
204
+ this.scheduleFlush();
205
+ }
206
+ /** 缺省时钟:注入的 now 抛错或给出非有限值时 fail-closed(返回 NaN,由调用方校验丢弃) */
207
+ clockNow() {
208
+ try {
209
+ return this.now();
210
+ }
211
+ catch {
212
+ return Number.NaN;
213
+ }
214
+ }
215
+ /** 该会话最后一次活动时刻;没记过 = null(**不是 0**,0 会被误当成有效时间戳) */
216
+ lastActivityAt(sessionId) {
217
+ const list = this.table.get(sessionId);
218
+ if (list === undefined || list.length === 0)
219
+ return null;
220
+ const last = list[list.length - 1];
221
+ return last === undefined ? null : last.to;
222
+ }
223
+ /** 该会话是否有任何窗口(用于区分「确知没活动」与「根本没采到」) */
224
+ hasDataFor(sessionId) {
225
+ const list = this.table.get(sessionId);
226
+ return list !== undefined && list.length > 0;
227
+ }
228
+ /** 活跃窗口包含 at(可容忍 toleranceMs,缺省 0)的会话 id,按 id 升序、去重 */
229
+ sessionsActiveAt(at, toleranceMs = 0) {
230
+ const out = [];
231
+ if (typeof at !== 'number' || !Number.isFinite(at))
232
+ return out;
233
+ const tolerance = Number.isFinite(toleranceMs) && toleranceMs > 0 ? toleranceMs : 0;
234
+ for (const [sessionId, windows] of this.table) {
235
+ for (const iv of windows) {
236
+ if (at >= iv.from - tolerance && at <= iv.to + tolerance) {
237
+ out.push(sessionId);
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ return out.sort();
243
+ }
244
+ /** 单个会话是否活跃(与 sessionsActiveAt 同口径,不另写一套判定) */
245
+ isActiveAt(sessionId, at, toleranceMs = 0) {
246
+ if (typeof sessionId !== 'string' || sessionId === '')
247
+ return false;
248
+ return this.sessionsActiveAt(at, toleranceMs).includes(sessionId);
249
+ }
250
+ /** 延迟落盘(多次调用只落一次) */
251
+ scheduleFlush() {
252
+ if (this.flushDelayMs <= 0 || this.flushTimer !== null)
253
+ return;
254
+ this.flushTimer = setTimeout(() => {
255
+ this.flushTimer = null;
256
+ void this.flush();
257
+ }, this.flushDelayMs);
258
+ this.flushTimer.unref?.();
259
+ }
260
+ /** 内存快照(深拷一层:落盘期间的新活动不得改写正在序列化的数据) */
261
+ snapshot() {
262
+ const clone = new Map();
263
+ for (const [sessionId, list] of this.table) {
264
+ clone.set(sessionId, list.map((iv) => ({ sessionId: iv.sessionId, from: iv.from, to: iv.to })));
265
+ }
266
+ return clone;
267
+ }
268
+ /** 淘汰最旧的窗口(按 to 升序删到上限内;条目 = 窗口,不是会话) */
269
+ evictOldest() {
270
+ const flat = [];
271
+ for (const list of this.table.values())
272
+ flat.push(...list);
273
+ const over = flat.length - this.maxIntervals;
274
+ if (over <= 0)
275
+ return;
276
+ flat.sort((a, b) => a.to - b.to || a.from - b.from);
277
+ const doomed = new Set(flat.slice(0, over));
278
+ for (const [sessionId, list] of [...this.table]) {
279
+ const kept = list.filter((iv) => !doomed.has(iv));
280
+ if (kept.length === 0)
281
+ this.table.delete(sessionId);
282
+ else
283
+ this.table.set(sessionId, kept);
284
+ }
285
+ }
286
+ /**
287
+ * 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛)。
288
+ *
289
+ * 幂等:没有未落盘数据(revision == lastFlushedRevision)时**直接返回、不碰磁盘**;
290
+ * 并发调用共用同一个在飞写入。
291
+ */
292
+ async flush() {
293
+ if (this.writing !== null)
294
+ return this.writing;
295
+ if (this.revision === this.lastFlushedRevision)
296
+ return;
297
+ this.writing = (async () => {
298
+ const captured = this.revision;
299
+ try {
300
+ await fs.mkdir(path.dirname(this.file), { recursive: true });
301
+ const merged = this.snapshot();
302
+ // **写前合并**(2026-10-17 修):本进程内存里只有自己见过的活动,而同一个工作区可能同时被
303
+ // 另一个 DSH 进程写台账 —— 各自「整体覆盖」会把对方的时间窗抹掉。窗口只增不减
304
+ // ⇒ 与磁盘做**并集合并**是唯一不丢信息的口径(同键取较新的那套在这里不适用:
305
+ // 时间窗不是「最后一次是谁」,而是「谁在这段时间活过」,两个进程的窗口都要留)。
306
+ try {
307
+ const existing = JSON.parse(await fs.readFile(this.file, 'utf8'));
308
+ if (existing !== null && typeof existing === 'object' && !Array.isArray(existing) && existing.version === 1 && Array.isArray(existing.intervals)) {
309
+ for (const raw of existing.intervals) {
310
+ const iv = readInterval(raw);
311
+ if (iv === null)
312
+ continue;
313
+ mergeRangeInto(merged, this.gapMs, iv.sessionId, iv.from, iv.to);
314
+ }
315
+ }
316
+ }
317
+ catch {
318
+ /* 读不到就当没有别人的记录:只写自己这份(绝不因为读失败而放弃落盘) */
319
+ }
320
+ const payload = { version: 1, intervals: flattenIntervals(merged) };
321
+ const tmp = this.file + '.tmp';
322
+ await fs.writeFile(tmp, JSON.stringify(payload), 'utf8');
323
+ await fs.rename(tmp, this.file);
324
+ this.lastFlushedRevision = captured;
325
+ }
326
+ catch (error) {
327
+ this.warn('rollback activity write failed: ' + this.file + ' ' + String(error.code ?? error));
328
+ }
329
+ finally {
330
+ this.writing = null;
331
+ }
332
+ })();
333
+ return this.writing;
334
+ }
335
+ /**
336
+ * 关闭:取消待落盘的定时器,并把还没落盘的活动写下去。
337
+ * 没有未落盘数据时不会重复写(flush 幂等);写入失败只 warn,不透出(关停路径不得被台账拖垮)。
338
+ */
339
+ async dispose() {
340
+ if (this.flushTimer !== null) {
341
+ clearTimeout(this.flushTimer);
342
+ this.flushTimer = null;
343
+ }
344
+ await this.flush();
345
+ // 在飞写入期间又记了新活动 ⇒ 再落一次(最多两次,绝不循环重试)
346
+ if (this.revision !== this.lastFlushedRevision)
347
+ await this.flush();
348
+ }
349
+ }
350
+ //# sourceMappingURL=rollback-activity.js.map
@@ -122,15 +122,6 @@ export interface RollbackTempCleanupResult {
122
122
  /** 形似临时文件但**判据不足**(判不准就不删) */
123
123
  skipped: number;
124
124
  }
125
- /**
126
- * 删除 rollback-points/ 下**形状可自证**的崩溃残留原子写临时文件。
127
- *
128
- * 判据(三条全中才删,判不准一律不删):
129
- * 1. 名字逐字符合 atomic-write.ts 的 tmpPath() 形态:.dshcm.<base>.<pid>.<16hex>.tmp;
130
- * 2. 该 pid **不是本进程**(本进程的 tmp 可能正在写);
131
- * 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
132
- * 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
133
- */
134
125
  export declare function cleanupOrphanTempFiles(root: string): Promise<RollbackTempCleanupResult>;
135
126
  /** 清单条目校验:形状不对就**丢掉并计数**(与 v2 的 records.filter(isRollbackPointRecord) 同一条路) */
136
127
  export declare function normalizeManifestEntry(value: unknown): RollbackLedgerManifestEntry | null;
@@ -38,6 +38,7 @@ import { StringDecoder } from 'node:string_decoder';
38
38
  import { atomicWriteFile } from '../utils/atomic-write.js';
39
39
  import { isRecord } from '../utils/guards.js';
40
40
  import { sha256Hex } from '../utils/hashing.js';
41
+ import { UNDO_DONE_FILE_NAME, UNDO_JOURNAL_FILE_NAME } from './rollback-undo-journal.js';
41
42
  /* ------------------------------------------------------------------ 常量 */
42
43
  /** 清单格式版本(与**记录**的 schemaVersion 无关:后者是记录内容的版本,恒为 2) */
43
44
  export const ROLLBACK_LEDGER_SCHEMA_VERSION = 3;
@@ -300,6 +301,28 @@ function isProcessAlive(pid) {
300
301
  * 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
301
302
  * 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
302
303
  */
304
+ /**
305
+ * 这个 undo 目录是不是「**回退写到一半就没了**」的现场(有 journal 索引、但没有 done 标记)。
306
+ *
307
+ * 判据必须与 journal 模块**同一份常量**(import 而不是抄字面量):名字改了这里要跟着改,否则
308
+ * 要么把现场当垃圾删掉(灾难),要么永远不清残留(垃圾堆积)。
309
+ */
310
+ async function hasUnfinishedJournal(dir) {
311
+ try {
312
+ await fs.access(path.join(dir, UNDO_DONE_FILE_NAME));
313
+ return false;
314
+ }
315
+ catch {
316
+ /* 没有 done:再看有没有 journal 索引 */
317
+ }
318
+ try {
319
+ await fs.access(path.join(dir, UNDO_JOURNAL_FILE_NAME));
320
+ return true;
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ }
303
326
  export async function cleanupOrphanTempFiles(root) {
304
327
  const out = { removed: 0, bytes: 0, skipped: 0 };
305
328
  let dirs;
@@ -372,6 +395,20 @@ export async function cleanupOrphanTempFiles(root) {
372
395
  continue;
373
396
  }
374
397
  const target = path.join(undoRoot, name);
398
+ // **例外(2026-10-18)**:有 journal 却没有 done ⇒ 这不是垃圾,是「回退写到一半进程就没了」
399
+ // 的现场。必须留住:既能被 findInterruptedUndos 认出来,也能按 after 镜像安全重放
400
+ //(把工作区退回回退前)。删掉它 = 让半回退的工作区变成无人知晓的残留。
401
+ try {
402
+ if (await hasUnfinishedJournal(target)) {
403
+ out.skipped += 1;
404
+ continue;
405
+ }
406
+ }
407
+ catch {
408
+ // 判据自己读不出来 ⇒ **不碰**(宁可留垃圾,也不删可能还能救命的现场)
409
+ out.skipped += 1;
410
+ continue;
411
+ }
375
412
  try {
376
413
  const bytes = await dirBytes(target);
377
414
  await fs.rm(target, { recursive: true, force: true });
@@ -1,5 +1,6 @@
1
+ import { type TextShape } from './text-fidelity.ts';
1
2
  /** 合并结论(`ok:false` 时调用方必须**零写入**) */
2
- export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary';
3
+ export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary' | 'non-utf8';
3
4
  export interface ThreeWayMergeResult {
4
5
  /** true = 结果可直接写入;false = 必须放弃写入并如实报告 */
5
6
  ok: boolean;
@@ -12,11 +13,30 @@ export interface ThreeWayMergeResult {
12
13
  revertedLines: number;
13
14
  /** 依赖风险提示(启发式;空数组 = 未发现明显风险,**不代表安全**) */
14
15
  risks: string[];
16
+ /**
17
+ * 输出的**字节形态**(判据 3/5):落盘必须用 encodeTextBytes(result.text, result.shape) ——
18
+ * result.text 里**不含** BOM,BOM 由 shape.bom 承载。
19
+ */
20
+ shape: TextShape;
21
+ /** 输入里出现过**不止一种行尾风格**(单个文件内混合 / 各侧风格不同)⇒ 输出的行尾已被统一,不谎称「原样」 */
22
+ eolMixed: boolean;
23
+ /** 输出的行尾**不再逐字节等于输入**(含「显式 shape 与输入风格不同」)⇒ 行尾被改写,绝不静默 */
24
+ eolRewritten: boolean;
15
25
  }
16
26
  /**
17
27
  * 三方合并(纯函数、无 I/O)。
18
28
  *
29
+ * **文本保真(2026-10-18 缺陷:旧实现用 join('\n') 收尾 ⇒ 把整份 CRLF 文件转成 LF)**:
30
+ * · 形态(EOL / BOM)显式给了 options.shape 就按它;没给就从 **theirs 探测**(磁盘上真实的那一侧),
31
+ * 绝不擅自把 CRLF 改成 LF。落在未变更区域的行取自 base,且只含**行内容**(不含行尾),输出统一按
32
+ * shape.eol 收尾 —— 输入行尾若与输出不一致,用 eolMixed / eolRewritten 如实标注,不假装「原样」。
33
+ * · 输入按契约是**已解码文本**(BOM 已由 decodeTextBytes 剥离);残留的前导 U+FEFF 会被剥掉并记进
34
+ * shape.bom,免得调用方 encode 时写出双 BOM。落盘必须用 encodeTextBytes(result.text, result.shape)。
35
+ * · 输入若含孤立代理项或孤立 CR(TextShape 表达不了的形态)⇒ reason='non-utf8',**零写入**。
36
+ *
19
37
  * 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
20
38
  * 同时保留「别人从 base 改到 theirs 的那部分」。
21
39
  */
22
- export declare function mergeThreeWay(ours: string, base: string, theirs: string): ThreeWayMergeResult;
40
+ export declare function mergeThreeWay(ours: string, base: string, theirs: string, options?: {
41
+ shape?: TextShape;
42
+ }): ThreeWayMergeResult;
@@ -22,6 +22,7 @@
22
22
  * 给出**启发式**风险提示(`risks`)—— 它不构成「删除不安全」的证明,也绝不因此声称合并正确。
23
23
  */
24
24
  import { diffLines, looksBinary } from '../utils/line-diff.js';
25
+ import { isUtf8RoundTrippable, probeTextForm } from './text-fidelity.js';
25
26
  /** 标识符取样用的最小长度(太短的词(i、id)同名词满天飞,只会制造噪声) */
26
27
  const RISK_TOKEN_MIN = 4;
27
28
  /** 不参与依赖提示的通用词(语言关键字 / 极常见标识符) */
@@ -113,33 +114,77 @@ function riskTokens(lines) {
113
114
  }
114
115
  return [...seen];
115
116
  }
117
+ /** 合并输入必须是「UTF-8 往返安全 + 行尾可无损表示」的文本(否则绝不合并、绝不写回) */
118
+ function isMergableInput(text) {
119
+ return isUtf8RoundTrippable(text) && probeTextForm(text).loneCr === 0;
120
+ }
121
+ /** 剥掉**一个**前导 U+FEFF(BOM 由 shape.bom 承载,正文里不该再残留它) */
122
+ function stripBomPrefix(text) {
123
+ return text.startsWith('\uFEFF') ? text.slice(1) : text;
124
+ }
116
125
  /**
117
126
  * 三方合并(纯函数、无 I/O)。
118
127
  *
128
+ * **文本保真(2026-10-18 缺陷:旧实现用 join('\n') 收尾 ⇒ 把整份 CRLF 文件转成 LF)**:
129
+ * · 形态(EOL / BOM)显式给了 options.shape 就按它;没给就从 **theirs 探测**(磁盘上真实的那一侧),
130
+ * 绝不擅自把 CRLF 改成 LF。落在未变更区域的行取自 base,且只含**行内容**(不含行尾),输出统一按
131
+ * shape.eol 收尾 —— 输入行尾若与输出不一致,用 eolMixed / eolRewritten 如实标注,不假装「原样」。
132
+ * · 输入按契约是**已解码文本**(BOM 已由 decodeTextBytes 剥离);残留的前导 U+FEFF 会被剥掉并记进
133
+ * shape.bom,免得调用方 encode 时写出双 BOM。落盘必须用 encodeTextBytes(result.text, result.shape)。
134
+ * · 输入若含孤立代理项或孤立 CR(TextShape 表达不了的形态)⇒ reason='non-utf8',**零写入**。
135
+ *
119
136
  * 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
120
137
  * 同时保留「别人从 base 改到 theirs 的那部分」。
121
138
  */
122
- export function mergeThreeWay(ours, base, theirs) {
139
+ export function mergeThreeWay(ours, base, theirs, options) {
140
+ // 形态:显式优先;未显式给出时从 theirs 探测(主导 EOL + BOM 前缀)。
141
+ const oursForm = probeTextForm(ours);
142
+ const baseForm = probeTextForm(base);
143
+ const theirsForm = probeTextForm(theirs);
144
+ const hadBom = oursForm.bom || baseForm.bom || theirsForm.bom;
145
+ const eol = options?.shape?.eol ?? theirsForm.eol;
146
+ // 显式形态优先;但输入侧确实带过 BOM 时绝不静默丢弃(丢 BOM 属内容改动)
147
+ const shape = { eol, bom: options?.shape ? options.shape.bom || hadBom : hadBom };
148
+ const styles = new Set();
149
+ for (const form of [oursForm, baseForm, theirsForm]) {
150
+ for (const style of form.eols)
151
+ styles.add(style);
152
+ }
153
+ /** 输入里出现过不止一种行尾风格 */
154
+ const eolMixed = styles.size > 1;
155
+ /** 输出的行尾不再逐字节等于输入(已被统一) */
156
+ const eolRewritten = eolMixed || (styles.size === 1 && !styles.has(eol));
157
+ // ok:false 时 text 一律是**调用方给的 theirs 原文**(绝不落入半截合并结果)
123
158
  const empty = (reason, conflicts = 0, risks = []) => ({
124
- ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks,
159
+ ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks, shape, eolMixed, eolRewritten,
125
160
  });
126
161
  // 没有基线就退化成「谁的改动都不认识」—— 调用方负责在无 base 时根本不调用本函数
127
162
  if (looksBinary(base) || looksBinary(ours) || looksBinary(theirs))
128
163
  return empty('binary');
129
- if (ours === base) {
164
+ // 非法 / 无法无损表示的文本:孤立代理项(encode 时会被替换成 U+FFFD)、孤立 CR(行尾表示不了)
165
+ if (![ours, base, theirs].every(isMergableInput))
166
+ return empty('non-utf8');
167
+ // 前导 U+FEFF 归 shape.bom 管;正文里剥掉它,免得它被当成「行内容」混进 diff
168
+ const oursText = stripBomPrefix(ours);
169
+ const baseText = stripBomPrefix(base);
170
+ const theirsText = stripBomPrefix(theirs);
171
+ const clean = (text) => ({
172
+ ok: true, text, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [], shape, eolMixed, eolRewritten,
173
+ });
174
+ if (oursText === baseText) {
130
175
  // 本会话没改过 ⇒ 结论就是「保持别人现在的内容」(不是失败)
131
- return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
176
+ return clean(theirsText);
132
177
  }
133
- if (theirs === base) {
178
+ if (theirsText === baseText) {
134
179
  // 别人没改过 ⇒ 直接回到 ours
135
- return { ok: true, text: ours, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
180
+ return clean(oursText);
136
181
  }
137
- if (ours === theirs) {
182
+ if (oursText === theirsText) {
138
183
  // 双方最终内容一致 ⇒ 谁都不用动
139
- return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
184
+ return clean(theirsText);
140
185
  }
141
- const oursBlocks = changeBlocks(base, ours);
142
- const theirsBlocks = changeBlocks(base, theirs);
186
+ const oursBlocks = changeBlocks(baseText, oursText);
187
+ const theirsBlocks = changeBlocks(baseText, theirsText);
143
188
  if (oursBlocks === null || theirsBlocks === null)
144
189
  return empty('degraded');
145
190
  // 冲突扫描:任一 ours 块与任一 theirs 块触碰且内容不同 ⇒ 冲突
@@ -155,7 +200,7 @@ export function mergeThreeWay(ours, base, theirs) {
155
200
  }
156
201
  if (conflicts > 0)
157
202
  return empty('conflict', conflicts);
158
- const baseLines = splitLinesOf(base);
203
+ const baseLines = splitLinesOf(baseText);
159
204
  const merged = [];
160
205
  const reverted = [];
161
206
  let cursor = 0;
@@ -183,12 +228,17 @@ export function mergeThreeWay(ours, base, theirs) {
183
228
  }
184
229
  for (let i = cursor; i < baseLines.length; i += 1)
185
230
  merged.push(baseLines[i]);
231
+ // 行尾统一按 shape.eol 收尾(混合 / 被改写的行尾已由 eolMixed / eolRewritten 如实标注);
186
232
  // 尾换行以**当前磁盘**(theirs)为准:我们是在它的基础上撤销本会话的改动,不擅改它原本的收尾形态
187
- const text = merged.join('\n') + (merged.length > 0 && theirs.endsWith('\n') ? '\n' : '');
233
+ const tail = merged.length > 0 && theirsText.endsWith('\n') ? eol : '';
234
+ const text = merged.join(eol) + tail;
188
235
  // 依赖风险(启发式):被撤销的行里出现过、且合并结果里仍能读到的标识符
189
236
  const tokens = riskTokens(reverted);
190
237
  const risks = tokens.filter((token) => new RegExp('\\b' + token + '\\b').test(text)).slice(0, 8);
191
- return { ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks };
238
+ return {
239
+ ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks,
240
+ shape, eolMixed, eolRewritten,
241
+ };
192
242
  }
193
243
  /** 与 `splitLines` 同口径的本地折行(避免为了一个函数再引一层依赖) */
194
244
  function splitLinesOf(text) {