dsh-config-manager 0.1.76 → 0.1.77

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 (71) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.js +92 -12
  6. package/lib/client.d.ts +104 -9
  7. package/lib/client.js +1796 -714
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-merge.d.ts +22 -0
  13. package/lib/core/rollback-merge.js +203 -0
  14. package/lib/core/rollback-point.d.ts +114 -0
  15. package/lib/core/rollback-point.js +534 -17
  16. package/lib/core/types.d.ts +18 -0
  17. package/lib/index.d.ts +26 -3
  18. package/lib/index.js +129 -16
  19. package/lib/routes/recovery.js +11 -0
  20. package/lib/schema/section-registry.js +5 -5
  21. package/lib/ui/badge-tone.d.ts +16 -0
  22. package/lib/ui/badge-tone.js +20 -0
  23. package/lib/ui/i18n.d.ts +4 -4
  24. package/lib/ui/i18n.js +8 -8
  25. package/lib/ui/rollback-view.d.ts +95 -0
  26. package/lib/ui/rollback-view.js +55 -0
  27. package/lib/ui/selection-model.d.ts +140 -1
  28. package/lib/ui/selection-model.js +241 -9
  29. package/package.json +1 -1
  30. package/src/adapters/plugin-data-declared.ts +60 -0
  31. package/src/adapters/plugin-data.ts +7 -33
  32. package/src/adapters/plugins.ts +98 -13
  33. package/src/adapters/units.test.ts +26 -0
  34. package/src/client/chat/ChatEntryView.tsx +147 -46
  35. package/src/client/chat/chat-entry-view.test.ts +277 -1
  36. package/src/client/chat/chat-entry-view.ts +98 -4
  37. package/src/client/chat/chat-node-registration.ts +71 -0
  38. package/src/client/chat/chat-slots.ts +30 -12
  39. package/src/client/common/ContentPicker.tsx +581 -175
  40. package/src/client/common/host-primitives.ts +64 -1
  41. package/src/client/common/ui.tsx +17 -3
  42. package/src/client/config-manager.module.css +630 -397
  43. package/src/client/css-scale-guard.test.ts +41 -3
  44. package/src/client/css-token-guard.test.ts +10 -4
  45. package/src/client/export/ExportView.tsx +2 -0
  46. package/src/client/index.ts +15 -3
  47. package/src/client/locales.ts +54 -10
  48. package/src/client/recovery/RollbackCard.tsx +56 -2
  49. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  50. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  51. package/src/client/recovery/recovery-locales.ts +22 -0
  52. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  53. package/src/core/messages.ts +2 -0
  54. package/src/core/rollback-attribution.test.ts +120 -0
  55. package/src/core/rollback-attribution.ts +267 -0
  56. package/src/core/rollback-merge.test.ts +133 -0
  57. package/src/core/rollback-merge.ts +213 -0
  58. package/src/core/rollback-point.test.ts +412 -0
  59. package/src/core/rollback-point.ts +560 -20
  60. package/src/core/types.ts +19 -0
  61. package/src/index.facade.test.ts +60 -0
  62. package/src/index.ts +133 -19
  63. package/src/routes/recovery.ts +11 -0
  64. package/src/schema/section-registry.ts +5 -5
  65. package/src/ui/badge-tone.test.ts +23 -0
  66. package/src/ui/badge-tone.ts +27 -0
  67. package/src/ui/i18n.ts +8 -8
  68. package/src/ui/rollback-view.test.ts +27 -0
  69. package/src/ui/rollback-view.ts +139 -0
  70. package/src/ui/selection-model.test.ts +240 -2
  71. package/src/ui/selection-model.ts +349 -1
@@ -73,6 +73,8 @@ import { EXCLUDED_DEP_DIR_NAMES } from '../utils/recursive-walk.ts'
73
73
  import { diffLines, type DiffHunk } from '../utils/line-diff.ts'
74
74
  import { projectKeyOf } from './session-select.ts'
75
75
  import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, type IgnoreRules } from './rollback-ignore-rules.ts'
76
+ import type { RollbackWriteAttributionPort, RollbackWriteFact, RollbackWriteRecorder } from './rollback-attribution.ts'
77
+ import { mergeThreeWay } from './rollback-merge.ts'
76
78
  import { isSessionLogName } from '../utils/session-log.ts'
77
79
  import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.ts'
78
80
  import {
@@ -746,6 +748,23 @@ export interface RollbackPreviewFile {
746
748
  * **宁可说「不知道」,也绝不谎报行数。**
747
749
  */
748
750
  truncated: boolean
751
+ /**
752
+ * true = 该文件在留点**之后**是**别的会话**写的(写入归属台账证明;见 core/rollback-attribution.ts)。
753
+ * 界面据此默认不勾选它(用户仍可勾上)。缺字段 / false = **没有归属证据**,
754
+ * 而不是「本会话写的」——「未判定」与「确知是本会话」必须分开。
755
+ */
756
+ foreign?: boolean
757
+ /** 那些「别的会话」的会话 id(仅当 foreign 为 true) */
758
+ foreignSessionId?: string
759
+ /** 那次写入的时间(毫秒;仅当 foreign 为 true) */
760
+ foreignAt?: number
761
+ /**
762
+ * true = 这次回退**不会改动它**:要么与其它对话的改动冲突、要么无法安全合并(缺共同祖先 / 二进制 /
763
+ * 差异被降级 / 对方删了它)。行级统计一律为 null —— 「不改动」不是「0 行变化」。
764
+ */
765
+ skipped?: boolean
766
+ /** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
767
+ mergeReason?: string
749
768
  }
750
769
 
751
770
  /** 单文件差异里一侧(当前磁盘 / 回退点快照)的展示信息。 */
@@ -790,6 +809,15 @@ export interface RollbackFileDiffResult {
790
809
  degraded: boolean
791
810
  /** 无法逐行对比的原因(有值时 hunks 为空) */
792
811
  reason?: RollbackFileDiffReason
812
+ /** true = 「回退后」一侧显示的是**三方合并**的结果,而不是记录里的原文 */
813
+ merged?: boolean
814
+ /**
815
+ * true = 这次回退**不会改动**它(与其它对话的改动冲突 / 无法安全合并)—— 此时 after 与 before 相同。
816
+ * 界面必须把「不会改动」与「0 行变化」分开说。
817
+ */
818
+ mergeSkipped?: boolean
819
+ /** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
820
+ mergeReason?: string
793
821
  limits: typeof ROLLBACK_FILE_DIFF_LIMITS
794
822
  }
795
823
 
@@ -964,6 +992,28 @@ export interface RollbackSegmentResult {
964
992
  */
965
993
  createdRefusedReason?: 'capture-incomplete'
966
994
  writtenBytes?: number
995
+ /**
996
+ * 因路径选择(onlyPaths)而未恢复 / 未删除的条目数。
997
+ * 与 createdSkipped(安全拒删)**分开**:这里的原因是用户自己没勾它,不是安全判据挡下。
998
+ */
999
+ selectionSkipped?: number
1000
+ /**
1001
+ * 因**归属隔离**(2026-10-17 定案 B)而跳过的记录条目数:该文件在留点之后是**别的会话**写的。
1002
+ * 只在**没有显式点名**(`onlyPaths` 缺省)时生效;显式点名 = 用户明确要它,归属不再拦。
1003
+ */
1004
+ foreignSkipped?: number
1005
+ /** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(有界清单同给) */
1006
+ mergedCount?: number
1007
+ mergedPaths?: string[]
1008
+ /** 冲突:两侧改到同一处 ⇒ **未写**,交给用户处理 */
1009
+ conflictCount?: number
1010
+ conflictPaths?: string[]
1011
+ /** 无法安全撤销(无中间版本 / 二进制 / 差异被降级 / 对方改成了不存在)⇒ **未写** */
1012
+ unavailableCount?: number
1013
+ unavailablePaths?: string[]
1014
+ /** 写入前发现又被改过(并发)⇒ **未写** */
1015
+ concurrentCount?: number
1016
+ concurrentPaths?: string[]
967
1017
  /** 会话段:fork 出来的子会话 id */
968
1018
  childSessionId?: string
969
1019
  guidance?: RollbackGuidance[]
@@ -984,6 +1034,16 @@ export interface RollbackRewindInput {
984
1034
  * (把已写回的文件还原、把已删的文件写回)。拒删判据见 createdRemovalRefusal。
985
1035
  */
986
1036
  removeCreated?: boolean
1037
+ /**
1038
+ * **路径级选择性恢复**(2026-10-17 用户定案 A):只恢复 / 只删除这些路径。
1039
+ *
1040
+ * 缺省(undefined)= 既有全量口径,行为一字不变。给了值就:
1041
+ * · 记录在案的文件里**不在**清单中的:不写(计入 selectionSkipped);
1042
+ * · 「留点后新建」的候选里**不在**清单中的:不删(同样计入)。
1043
+ * 清单里出现**既未记录、也不在删除候选**里的路径 ⇒ **fail-closed 拒绝、零写入**(invalid-input):
1044
+ * 绝不把「用户打错一个路径」变成「静默少恢复几个文件」。
1045
+ */
1046
+ onlyPaths?: readonly string[]
987
1047
  }
988
1048
 
989
1049
  export interface RollbackRewindResult {
@@ -1008,6 +1068,11 @@ export type RollbackStorageProbe = () => Promise<{ ok: true } | { ok: false; det
1008
1068
  export interface RollbackEngineOptions {
1009
1069
  /** 插件数据目录(= $DSH_HOME/dsh-config-manager);回滚点库在 dataDir/rollback-points */
1010
1070
  dataDir: string
1071
+ /**
1072
+ * 写入归属台账(2026-10-17 用户定案 B)。缺省 = 没有归属信息 ⇒ 预览不标 foreign,
1073
+ * 回退行为与旧版**逐字一致**(未判定绝不当作「别的会话写的」)。
1074
+ */
1075
+ attribution?: RollbackWriteAttributionPort
1011
1076
  /** DSH home(会话根 = homeDir/sessions) */
1012
1077
  homeDir: string
1013
1078
  now?: () => number
@@ -1787,6 +1852,8 @@ export class RollbackPointEngine {
1787
1852
  private ledgerSnapshotCache: { at: number; entries: RollbackLedgerManifestEntry[] } | null = null
1788
1853
  /** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
1789
1854
  private readonly ledger: RollbackLedger
1855
+ /** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
1856
+ private readonly attribution: RollbackWriteAttributionPort | undefined
1790
1857
  private readonly ledgerBytesBudget: number
1791
1858
  /**
1792
1859
  * 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
@@ -1843,6 +1910,7 @@ export class RollbackPointEngine {
1843
1910
  this.maxGuards = options.maxGuards ?? ROLLBACK_MAX_GUARDS
1844
1911
  this.guardPolicy = options.guardPolicy ?? 'warn'
1845
1912
  this.probe = options.storage
1913
+ this.attribution = options.attribution
1846
1914
  this.forkSeam = options.fork
1847
1915
  this.writeSeam = options.writeFile
1848
1916
  this.readSeam = options.readFile
@@ -3668,6 +3736,36 @@ export class RollbackPointEngine {
3668
3736
  return { added: counts.added, removed: counts.removed, truncated: false }
3669
3737
  }
3670
3738
 
3739
+ /**
3740
+ * **合并结果**的逐文件行级统计(与 `previewFileCounts` 同一套预算与降级判据)。
3741
+ *
3742
+ * 为什么单独一份:合并后要写进去的内容既不是记录里的原文(ours),也不是磁盘现状(theirs)——
3743
+ * 预览若照旧拿 ours 算差异,就会把「其实会被保留的对方改动」显示成「会被删掉」。
3744
+ */
3745
+ private async previewMergeCounts(
3746
+ targetBytes: Uint8Array,
3747
+ current: Uint8Array,
3748
+ budget: { bytes: number; work: number },
3749
+ ): Promise<{ added: number | null; removed: number | null; truncated: boolean }> {
3750
+ if (targetBytes.byteLength > this.previewDiffMaxFileBytes || current.byteLength > this.previewDiffMaxFileBytes) {
3751
+ return previewCountsUnavailable()
3752
+ }
3753
+ if (budget.bytes + targetBytes.byteLength + current.byteLength > this.previewDiffMaxTotalBytes) {
3754
+ return previewCountsUnavailable()
3755
+ }
3756
+ budget.bytes += targetBytes.byteLength + current.byteLength
3757
+ if (looksBinary(targetBytes) || looksBinary(current)) return previewCountsUnavailable()
3758
+ const counts = lineDiffCounts(
3759
+ splitDiffLines(DIFF_TEXT_DECODER.decode(current)),
3760
+ splitDiffLines(DIFF_TEXT_DECODER.decode(targetBytes)),
3761
+ this.previewDiffMaxWork,
3762
+ this.previewDiffTotalWork,
3763
+ budget,
3764
+ )
3765
+ if (counts === null) return previewCountsUnavailable()
3766
+ return { added: counts.added, removed: counts.removed, truncated: false }
3767
+ }
3768
+
3671
3769
  /** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
3672
3770
  async preview(id: string): Promise<RollbackPreviewResult> {
3673
3771
  const storage = await this.storageStatus()
@@ -3720,8 +3818,21 @@ export class RollbackPointEngine {
3720
3818
  let missing = 0
3721
3819
  // E1:逐文件行级统计 —— **只列会被改动的文件**(内容已一致的一个都不列)
3722
3820
  const changedFiles: RollbackPreviewFile[] = []
3821
+ // 写入归属(2026-10-17 定案 B):只有**确知**最后写入者是别的会话、且写入发生在留点**之后**时,
3822
+ // 才敢标 foreign。缺归属 / 归属就是本会话 / 写入早于留点 ⇒ 一律不标
3823
+ // (未判定 ≠ 别的会话写的;宁可少标一次,也不误判一次)。
3824
+ const foreignWriterOf = (target: string): RollbackWriteFact | undefined => {
3825
+ const writer = this.attribution?.writerOf(target)
3826
+ if (writer === undefined) return undefined
3827
+ if (writer.sessionId === record.sessionId) return undefined
3828
+ if (writer.at <= wsTrack.capturedAt) return undefined
3829
+ return writer
3830
+ }
3723
3831
  // 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
3724
3832
  const diffBudget = { bytes: 0, work: 0 }
3833
+ /** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
3834
+ const previewIsolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
3835
+ const previewBaseCache = new Map<string, string | null>()
3725
3836
  // T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
3726
3837
  const previewFiles = wsPayload.files
3727
3838
  // 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
@@ -3759,7 +3870,12 @@ export class RollbackPointEngine {
3759
3870
  // 计入 different 的理由:回退一定会尝试写它(restoreWorkspace 里「已一致」判定失败即视为需要写),
3760
3871
  // 这样 overwrite = 已一致 + different + missing 的算术仍然成立。
3761
3872
  different += 1
3762
- changedFiles.push({ path: file.path, status: 'overwrite', ...previewCountsUnavailable() })
3873
+ const foreignMissing = foreignWriterOf(file.path)
3874
+ changedFiles.push({
3875
+ path: file.path, status: 'overwrite',
3876
+ ...(foreignMissing === undefined ? {} : { foreign: true, foreignSessionId: foreignMissing.sessionId, foreignAt: foreignMissing.at }),
3877
+ ...previewCountsUnavailable(),
3878
+ })
3763
3879
  continue
3764
3880
  }
3765
3881
  // 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
@@ -3778,10 +3894,37 @@ export class RollbackPointEngine {
3778
3894
  }
3779
3895
  }
3780
3896
  if (sizeMatches && current !== null && sha256Hex(current) === file.sha256) continue
3897
+ // **合并判定与执行同口径**(2026-10-17 修):预览必须说清「这次到底会写成什么」。
3898
+ // 之前预览只按「覆盖回记录版本」算差异,而执行端会先做三方合并 ⇒ 预览把对方后来的行
3899
+ // 也算成「会被删掉」,用户据此以为会丢别人的改动(真机:两行描述都显示要删,实际只删 A 那行)。
3900
+ const decision = await (async (): Promise<{ mode: 'overwrite' | 'merged' | 'skip' | 'unchanged'; bytes?: Uint8Array; reason?: string }> => {
3901
+ if (current === null || previewIsolationSessionId === null) return { mode: 'overwrite' }
3902
+ let baseText = previewBaseCache.get(pathKey(file.path))
3903
+ if (baseText === undefined) {
3904
+ baseText = await this.baseVersionFor(file.path, wsTrack.capturedAt, previewIsolationSessionId)
3905
+ previewBaseCache.set(pathKey(file.path), baseText)
3906
+ }
3907
+ const writer = this.attribution?.writerOf(file.path)
3908
+ const foreignEvidence = writer !== undefined && writer.sessionId !== previewIsolationSessionId && writer.at > wsTrack.capturedAt
3909
+ if (baseText === null && !foreignEvidence) return { mode: 'overwrite' }
3910
+ const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
3911
+ if (outcome.kind === 'write') return { mode: 'merged', bytes: outcome.bytes }
3912
+ if (outcome.kind === 'unchanged') return { mode: 'unchanged' }
3913
+ if (outcome.kind === 'blocked') return { mode: 'skip', reason: outcome.reason }
3914
+ return { mode: 'overwrite' }
3915
+ })()
3916
+ // 合并结论是「保持现状」⇒ 它不会被改动,不该出现在「会被改动」的清单里
3917
+ if (decision.mode === 'unchanged') continue
3781
3918
  different += 1
3919
+ const foreignWriter = foreignWriterOf(file.path)
3782
3920
  changedFiles.push({
3783
3921
  path: file.path, status: 'overwrite',
3784
- ...(await this.previewFileCounts(file, current, 'overwrite', diffBudget)),
3922
+ ...(foreignWriter === undefined ? {} : { foreign: true, foreignSessionId: foreignWriter.sessionId, foreignAt: foreignWriter.at }),
3923
+ ...(decision.mode === 'skip'
3924
+ ? { skipped: true, mergeReason: decision.reason, ...previewCountsUnavailable() }
3925
+ : decision.mode === 'merged' && decision.bytes !== undefined && current !== null
3926
+ ? await this.previewMergeCounts(decision.bytes, current, diffBudget)
3927
+ : await this.previewFileCounts(file, current, 'overwrite', diffBudget)),
3785
3928
  })
3786
3929
  }
3787
3930
  const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
@@ -3929,6 +4072,36 @@ export class RollbackPointEngine {
3929
4072
  else currentUnreadable = true
3930
4073
  }
3931
4074
 
4075
+ // **合并判定与执行同口径**(2026-10-17 修):「回退后」一侧必须显示这次**真正会写进去**的内容。
4076
+ // 以前它恒定显示记录里的原文(= 整文件覆盖的样子),于是预览列表写着「+0 −2」,点开「查看差异」
4077
+ // 却还是「−1,4 +1,1」—— 同一件事两个说法(真机 2026-10-17 用户当场发现)。
4078
+ let mergedAfter: Uint8Array | null = null
4079
+ /** true 只表示「after 侧真的是合并算出来的」——「不会改动」时 after 也是现状,但不算合并 */
4080
+ let mergedFlag = false
4081
+ let mergeSkippedReason: string | null = null
4082
+ const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
4083
+ if (current !== null && snapshot !== null && isolationSessionId !== null) {
4084
+ const baseText = await this.baseVersionFor(file.path, record.workspace.capturedAt, isolationSessionId)
4085
+ const writer = this.attribution?.writerOf(file.path)
4086
+ const foreignEvidence = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > record.workspace.capturedAt
4087
+ if (baseText !== null || foreignEvidence) {
4088
+ const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
4089
+ if (outcome.kind === 'write') {
4090
+ mergedAfter = outcome.bytes
4091
+ mergedFlag = true
4092
+ }
4093
+ // 合并结论 = 保持现状 ⇒ 「回退后」就是当前内容(该文件根本不会被写)
4094
+ else if (outcome.kind === 'unchanged') mergedAfter = current
4095
+ else if (outcome.kind === 'blocked') {
4096
+ // 冲突 / 无法安全合并 ⇒ 这次**不会写**它:「回退后」就是现状(两侧相同),
4097
+ // 界面据此说「不会改动」,绝不显示成「会被覆盖成记录原文」。
4098
+ mergeSkippedReason = outcome.reason
4099
+ mergedAfter = current
4100
+ }
4101
+ }
4102
+ }
4103
+ /** 回退后一侧的字节:合并结果优先,其次才是记录里的原文 */
4104
+ const afterBytes: Uint8Array | null = mergedAfter ?? snapshot
3932
4105
  const before: RollbackFileDiffSide = {
3933
4106
  exists: currentExists, bytes: currentBytes, lines: null,
3934
4107
  binary: false, oversized: currentOversized, unreadable: currentUnreadable,
@@ -3938,17 +4111,19 @@ export class RollbackPointEngine {
3938
4111
  before.lines = before.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(current)).length
3939
4112
  }
3940
4113
  const after: RollbackFileDiffSide = {
3941
- exists: snapshot !== null, bytes: snapshot?.byteLength ?? file.bytes, lines: null,
4114
+ exists: afterBytes !== null, bytes: afterBytes?.byteLength ?? file.bytes, lines: null,
3942
4115
  binary: false, oversized: snapshotOversized,
3943
- unreadable: snapshot === null && !snapshotOversized,
4116
+ unreadable: afterBytes === null && !snapshotOversized,
3944
4117
  }
3945
- if (snapshot !== null) {
3946
- after.binary = looksBinary(snapshot)
3947
- after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(snapshot)).length
4118
+ if (afterBytes !== null) {
4119
+ after.binary = looksBinary(afterBytes)
4120
+ after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(afterBytes)).length
3948
4121
  }
3949
4122
  const view: RollbackFileDiffResult = {
3950
4123
  ...base, ok: true, code: 'ok', path: file.path,
3951
4124
  status: currentExists ? 'overwrite' : 'create', before, after,
4125
+ ...(mergedFlag ? { merged: true } : {}),
4126
+ ...(mergeSkippedReason === null ? {} : { mergeSkipped: true, mergeReason: mergeSkippedReason }),
3952
4127
  }
3953
4128
  // 结构性不可比对:逐条给出原因(有原因 ⇒ hunks 恒为空,绝不给半截差异)
3954
4129
  if (snapshotOversized) return { ...view, reason: 'too-large' }
@@ -3958,7 +4133,7 @@ export class RollbackPointEngine {
3958
4133
  if (before.binary || after.binary) return { ...view, reason: 'binary' }
3959
4134
  const result = diffLines(
3960
4135
  current === null ? '' : DIFF_TEXT_DECODER.decode(current),
3961
- DIFF_TEXT_DECODER.decode(snapshot),
4136
+ afterBytes === null ? '' : DIFF_TEXT_DECODER.decode(afterBytes),
3962
4137
  { context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD },
3963
4138
  )
3964
4139
  return {
@@ -4073,7 +4248,10 @@ export class RollbackPointEngine {
4073
4248
  continue
4074
4249
  }
4075
4250
  const segment = state === 'workspace'
4076
- ? await this.restoreWorkspace(record.workspace, { removeCreated: input.removeCreated === true })
4251
+ // 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
4252
+ ? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
4253
+ ? { removeCreated: input.removeCreated === true, sessionId: record.sessionId ?? '' }
4254
+ : { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.sessionId ?? '' })
4077
4255
  : await this.restoreSession(record.session)
4078
4256
  segments.push(segment)
4079
4257
  if (segment.childSessionId !== undefined) childSessionId = segment.childSessionId
@@ -4149,9 +4327,162 @@ export class RollbackPointEngine {
4149
4327
  return null
4150
4328
  }
4151
4329
 
4330
+ /**
4331
+ * 把一条「留点之后被**别的会话**写过」的记录做成三方合并结果(2026-10-17 跨对话安全回退)。
4332
+ *
4333
+ * 三方:`ours` = 记录里的内容(回退目标)、`base` = 目标点之后**最早**那份中间版本、
4334
+ * `theirs` = 当前磁盘。结果是「撤销本会话那次改动 + 保留对方后来的改动」。
4335
+ *
4336
+ * 返回:`write`(合并干净且确实要写,带目标哈希 + 期望的当前哈希)、
4337
+ * `unchanged`(合并结果就是当前内容 —— 对方没动这一处,本来就不需要写)、
4338
+ * `blocked`(不能安全处理:冲突 / 没有中间版本 / 二进制 / 差异被降级 / 对方改成了不存在 / 读不出)。
4339
+ */
4340
+ private async mergeForeignRecord(
4341
+ file: RollbackWorkspaceFile,
4342
+ baseText: string | null,
4343
+ foreignEvidence: boolean,
4344
+ ): Promise<
4345
+ | { kind: 'write'; bytes: Uint8Array; sha256: string; expectedCurrent: string }
4346
+ | { kind: 'unchanged' }
4347
+ | { kind: 'blocked'; reason: 'conflict' | 'no-base' | 'ambiguous' | 'binary' | 'degraded' | 'theirs-missing' | 'theirs-unreadable' | 'ours-unreadable' }
4348
+ /** 没有可用祖先、也没有「别人写过」的旁证 ⇒ 退回旧口径(整文件覆盖回目标版本) */
4349
+ | { kind: 'fallback' }
4350
+ > {
4351
+ let oursBytes: Uint8Array
4352
+ try {
4353
+ oursBytes = await this.readObject(file.object)
4354
+ if (sha256Hex(oursBytes) !== file.sha256) return { kind: 'blocked', reason: 'ours-unreadable' }
4355
+ } catch {
4356
+ return { kind: 'blocked', reason: 'ours-unreadable' }
4357
+ }
4358
+ let theirsBytes: Uint8Array
4359
+ try {
4360
+ theirsBytes = await fs.readFile(file.path)
4361
+ } catch (error) {
4362
+ return { kind: 'blocked', reason: (error as { code?: unknown }).code === 'ENOENT' ? 'theirs-missing' : 'theirs-unreadable' }
4363
+ }
4364
+ const oursText = Buffer.from(oursBytes).toString('utf8')
4365
+ // **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
4366
+ // 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
4367
+ // 于是「回退到该点」成了「什么都不做」(真机 R-25 就是这个形态被抓出来的:guard 之后
4368
+ // 那个点的内容与目标点相同)。
4369
+ if (baseText === null || baseText === oursText) {
4370
+ // 有「别人写过」的旁证却没有可用祖先 ⇒ 无法安全撤销,宁可不动它并如实报告;
4371
+ // 两样都没有 ⇒ 退回旧口径(整文件覆盖回目标版本)—— 那正是「纯本会话回退」的常见形态。
4372
+ if (foreignEvidence) return { kind: 'blocked', reason: 'no-base' }
4373
+ return { kind: 'fallback' }
4374
+ }
4375
+ // 二进制:任何一侧含 NUL 就绝不按文本合并(三方合并算法只对文本有定义)
4376
+ if (oursBytes.includes(0) || theirsBytes.includes(0) || baseText.includes('\u0000')) return { kind: 'blocked', reason: 'binary' }
4377
+ const theirsText = Buffer.from(theirsBytes).toString('utf8')
4378
+ const result = mergeThreeWay(oursText, baseText, theirsText)
4379
+ if (!result.ok) {
4380
+ return { kind: 'blocked', reason: result.reason === 'conflict' ? 'conflict' : result.reason === 'degraded' ? 'degraded' : 'binary' }
4381
+ }
4382
+ // 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
4383
+ if (oursText === theirsText) return { kind: 'unchanged' }
4384
+ if (result.text === theirsText) {
4385
+ // **合并结论 = 保持现状**:这次回退对工作区什么也做不了 —— 但 ours 与 theirs 明明不同
4386
+ //(否则上面那条就返回了)。真机 2026-10-17 的形状:base 取到了目标点**之后**的另一份快照
4387
+ //(里面已经含有「要撤销的那部分改动」),于是「应用对方的改动到 ours」恰好又变回现状。
4388
+ // 绝不能把它当成「无需改动」静默跳过 —— 那会让「回退」整个失效、界面上还看不到任何痕迹
4389
+ //(预览报 0 个文件、执行返回 nothing-to-restore)。这里按**无法安全撤销**如实处理:
4390
+ // 一个字节都不写,让用户看到并自己决定。
4391
+ return { kind: 'blocked', reason: 'ambiguous' }
4392
+ }
4393
+ const bytes = Buffer.from(result.text, 'utf8')
4394
+ return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) }
4395
+ }
4396
+
4397
+ /**
4398
+ * 三方合并的 **base**:目标点之后**最早**那份仍含该路径的中间版本(共同祖先)。
4399
+ *
4400
+ * 为什么这么取:回滚点的「工作区态」是回合开始前拍的 ⇒ 别的会话那个回合的点,
4401
+ * 正好是「本会话改动之后、对方改动之前」的版本 —— 标准三方合并的共同祖先。
4402
+ * 取**最早**而不是最近是刻意选安全方向:宁可少撤销,也绝不把对方更晚的改动算进 ours 侧。
4403
+ *
4404
+ * 排除四类:目标点自己(`createdAt > afterAt`)、**救援点 guard**(它在执行回退那一刻拍,
4405
+ * 内容 ≈ 当前磁盘,拿它当 base 会让合并结果直接等于 ours = 整文件覆盖,正是本机制要消灭的形态)、
4406
+ * **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
4407
+ * 处理,绝不用近似值代替)。
4408
+ */
4409
+ private async baseVersionFor(target: string, afterAt: number, excludeSessionId: string | null): Promise<string | null> {
4410
+ const ledger = await this.readManifest()
4411
+ const key = pathKey(target)
4412
+ const candidates = ledger.entries
4413
+ .filter((entry) => entry.createdAt > afterAt
4414
+ && entry.kind !== 'guard'
4415
+ // 只认**别的会话**的点:本会话后续回合的点当作 base,会把「本会话后续改动」也一并保留下来,
4416
+ // 那与「回退到该点之前」的直觉冲突;而别人的点正好是「本会话改动之后、对方改动之前」的版本。
4417
+ && (excludeSessionId === null || entry.sessionId !== excludeSessionId))
4418
+ .sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
4419
+ for (const entry of candidates) {
4420
+ const resolved = await this.readFullRecord(entry.id)
4421
+ const payload = resolved.record?.workspace.payload
4422
+ if (payload === null || payload === undefined) continue
4423
+ const hit = payload.files.find((item) => pathKey(item.path) === key)
4424
+ if (hit === undefined) continue
4425
+ try {
4426
+ const bytes = await this.readObject(hit.object)
4427
+ if (sha256Hex(bytes) !== hit.sha256) continue
4428
+ return Buffer.from(bytes).toString('utf8')
4429
+ } catch {
4430
+ continue
4431
+ }
4432
+ }
4433
+ return null
4434
+ }
4435
+
4436
+ /**
4437
+ * **别的会话的回合点里记录过的路径集合** —— 删除集保护的第二种证据(不依赖归属台账)。
4438
+ *
4439
+ * 为什么需要第二证据:归属可能整条缺失(工具不在白名单 / 另一个 DSH 进程把 attribution.json
4440
+ * 整体覆盖写 / 采集时机错过),而「别人的点里出现过这个路径」是**点台账本身**记录的事实 ——
4441
+ * 它足以证明「这条路径在留点之后被别的会话所在的回合捕获过」,因此删它就是销毁别人的工作。
4442
+ *
4443
+ * 有界:只扫目标点之后**最早**的 `limit` 个别的会话的点(按时间升序),且只读路径不读内容。
4444
+ * 扫不完不影响安全方向(少保护也会被归属那条兜一部分),但绝不因此声称「已扫全」。
4445
+ */
4446
+ private async foreignRecordedPaths(afterAt: number, excludeSessionId: string | null, limit = 10): Promise<Set<string>> {
4447
+ const out = new Set<string>()
4448
+ const ledger = await this.readManifest()
4449
+ const candidates = ledger.entries
4450
+ .filter((entry) => entry.createdAt > afterAt
4451
+ && entry.kind !== 'guard'
4452
+ && (excludeSessionId === null || entry.sessionId !== excludeSessionId))
4453
+ .sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
4454
+ .slice(0, Math.max(1, limit))
4455
+ for (const entry of candidates) {
4456
+ const resolved = await this.readFullRecord(entry.id)
4457
+ const payload = resolved.record?.workspace.payload as
4458
+ | (RollbackWorkspacePayload & { delta?: { added?: unknown; removed?: unknown } })
4459
+ | null
4460
+ | undefined
4461
+ if (payload === null || payload === undefined) continue
4462
+ const files = (payload as { files?: unknown }).files
4463
+ if (Array.isArray(files)) {
4464
+ for (const item of files) {
4465
+ const target = (item as { path?: unknown }).path
4466
+ if (typeof target === 'string' && target !== '') out.add(pathKey(target))
4467
+ }
4468
+ continue
4469
+ }
4470
+ const delta = payload.delta
4471
+ if (delta === null || delta === undefined) continue
4472
+ for (const bucket of [delta.added, delta.removed]) {
4473
+ if (!Array.isArray(bucket)) continue
4474
+ for (const item of bucket) {
4475
+ const target = typeof item === 'string' ? item : (item as { path?: unknown } | null)?.path
4476
+ if (typeof target === 'string' && target !== '') out.add(pathKey(target))
4477
+ }
4478
+ }
4479
+ }
4480
+ return out
4481
+ }
4482
+
4152
4483
  private async restoreWorkspace(
4153
4484
  track: RollbackTrack<RollbackWorkspacePayload>,
4154
- options: { removeCreated?: boolean } = {},
4485
+ options: { removeCreated?: boolean; onlyPaths?: readonly string[]; sessionId?: string } = {},
4155
4486
  ): Promise<RollbackSegmentResult> {
4156
4487
  const payload = track.payload
4157
4488
  if (!track.ok || payload === null) {
@@ -4162,11 +4493,78 @@ export class RollbackPointEngine {
4162
4493
  // (界面据此说「该点没记录文件」而不是「工作区已与该点一致」)。
4163
4494
  if (payload.files.length === 0) return this.noopSegment('workspace', 'no-recorded-files', 'files=0 nothingToDo=true')
4164
4495
  const detail: string[] = []
4165
- const writable: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
4496
+ // 路径级选择性恢复(2026-10-17 定案 A):null = 全量(旧口径,行为一字不变)。
4497
+ const selection = options.onlyPaths === undefined ? null : new Set(options.onlyPaths.map((target) => pathKey(target)))
4498
+ /** 因用户没勾选而跳过的记录条目数(只作事实回传,不参与任何安全判据) */
4499
+ let selectionSkipped = 0
4500
+ /**
4501
+ * 归属隔离(2026-10-17 定案 B):**没有显式点名**时,把「留点之后被别的会话写过」的记录条目
4502
+ * 自动跳过(上层据此如实报告)。只有 attribution 在、且这次回退知道自己属于哪个会话时才可能生效;
4503
+ * 缺任何一样 ⇒ 退化成旧口径(照常恢复)—— **未判定绝不当作别的会话写的**。
4504
+ */
4505
+ let foreignSkipped = 0
4506
+ const isolationSessionId = typeof options.sessionId === 'string' && options.sessionId !== '' ? options.sessionId : null
4507
+ /**
4508
+ * **合并保护**(2026-10-17 修):触发条件**不看归属**,只看「有没有共同祖先可用」。
4509
+ *
4510
+ * 为什么必须这样:归属台账是**旁证**而不是判据 —— 对方的写入可能因为工具不在白名单、
4511
+ * 或另一个 DSH 进程把 attribution.json 整体覆盖写而**没被记到**。真机 2026-10-17:
4512
+ * 两个对话各给 README 加了一行描述,回退其中一个之后**两行都没了**,根因正是
4513
+ * 「归属里只有本会话那次编辑」⇒ 不认为有第三方 ⇒ 退回整文件覆盖。
4514
+ * 只要目标点之后**存在别的会话的回合点**,那它就是共同祖先 ⇒ 必须走三方合并。
4515
+ */
4516
+ const mergeGuardEnabled = selection === null && isolationSessionId !== null
4517
+ /** 同一路径的 base 只查一次(合并判定在「确认需要写」之后,命中数很少) */
4518
+ const baseCache = new Map<string, string | null>()
4519
+ const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
4520
+ // 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
4521
+ // 为什么提到文件循环**之前**:选择模式要拿它当「合法路径」的第二来源(新建候选也允许被点名),
4522
+ // 而校验必须在**任何写入之前**完成。它是只读走查,提前算不会写任何东西。
4523
+ const plan = await this.planCreatedRemovals(payload, sink)
4524
+ if (selection !== null) {
4525
+ const legal = new Set<string>()
4526
+ for (const file of payload.files) legal.add(pathKey(file.path))
4527
+ for (const target of plan.all) legal.add(pathKey(target))
4528
+ const unknown = [...selection].filter((key) => !legal.has(key))
4529
+ if (unknown.length > 0) {
4530
+ // 点名了一个「既未记录、也不在删除候选」里的路径 ⇒ fail-closed:零写入失败,
4531
+ // 绝不把「用户打错一个路径」变成「静默少恢复几个文件」。
4532
+ pushDetail(detail, 'selection-unknown-path ' + unknown[0] + ' unknown=' + String(unknown.length) + ' zeroWrites=true')
4533
+ return {
4534
+ state: 'workspace', status: 'failed', reasonCode: 'invalid-input', detail,
4535
+ restored: 0, failed: unknown.length,
4536
+ leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT), leftoversTruncated: plan.truncated,
4537
+ }
4538
+ }
4539
+ }
4540
+ /**
4541
+ * 待写条目。`sha256` / `expectedCurrent` **只被三方合并路径**使用:
4542
+ * · `sha256` = 这次真正要写进去的内容哈希(普通路径恒等于记录里的 `file.sha256`);
4543
+ * · `expectedCurrent` = 合并时读到的「当前磁盘」哈希 —— 写入前必须仍是它,否则说明
4544
+ * 文件在预览/合并之后又被改过 ⇒ **放弃写入**(并发保护:绝不覆盖比我们更新的内容)。
4545
+ */
4546
+ const writable: { file: RollbackWorkspaceFile; bytes: Uint8Array; sha256?: string; expectedCurrent?: string }[] = []
4166
4547
  let failed = 0
4167
4548
  // 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
4168
4549
  let unchanged = 0
4550
+ /** 三方合并成功、写回「撤销本会话 + 保留对方改动」结果的条目数 */
4551
+ let mergedCount = 0
4552
+ /** 冲突:两侧改到同一处 ⇒ 绝不自动选边,交给用户 */
4553
+ let conflictCount = 0
4554
+ /** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
4555
+ let unavailableCount = 0
4556
+ /** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
4557
+ let concurrentCount = 0
4558
+ const mergedPaths: string[] = []
4559
+ const conflictPaths: string[] = []
4560
+ const unavailablePaths: string[] = []
4561
+ const concurrentPaths: string[] = []
4169
4562
  for (const file of payload.files) {
4563
+ // 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
4564
+ if (selection !== null && !selection.has(pathKey(file.path))) {
4565
+ selectionSkipped += 1
4566
+ continue
4567
+ }
4170
4568
  // 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
4171
4569
  const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk))
4172
4570
  if (!path.isAbsolute(file.path) || !inChunk || isInsidePath(file.path, this.root)) {
@@ -4192,16 +4590,66 @@ export class RollbackPointEngine {
4192
4590
  pushDetail(detail, 'object-hash-mismatch ' + file.path)
4193
4591
  continue
4194
4592
  }
4593
+ // **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
4594
+ // 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
4595
+ if (mergeGuardEnabled) {
4596
+ let base: string | null
4597
+ const cached = baseCache.get(pathKey(file.path))
4598
+ if (cached === undefined) {
4599
+ base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId)
4600
+ baseCache.set(pathKey(file.path), base)
4601
+ } else {
4602
+ base = cached
4603
+ }
4604
+ const foreignWriter = this.attribution?.writerOf(file.path)
4605
+ const foreignEvidence = foreignWriter !== undefined
4606
+ && foreignWriter.sessionId !== isolationSessionId
4607
+ && foreignWriter.at > track.capturedAt
4608
+ if (base !== null || foreignEvidence) {
4609
+ const outcome = await this.mergeForeignRecord(file, base, foreignEvidence)
4610
+ if (outcome.kind === 'fallback') {
4611
+ // 没有可用祖先、也没有别人的旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
4612
+ } else {
4613
+ if (outcome.kind === 'write') {
4614
+ writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent })
4615
+ mergedCount += 1
4616
+ if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) mergedPaths.push(file.path)
4617
+ continue
4618
+ }
4619
+ if (outcome.kind === 'unchanged') {
4620
+ unchanged += 1
4621
+ continue
4622
+ }
4623
+ foreignSkipped += 1
4624
+ if (outcome.reason === 'conflict') {
4625
+ conflictCount += 1
4626
+ if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) conflictPaths.push(file.path)
4627
+ } else {
4628
+ unavailableCount += 1
4629
+ if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) unavailablePaths.push(file.path)
4630
+ }
4631
+ pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true')
4632
+ continue
4633
+ }
4634
+ }
4635
+ }
4195
4636
  writable.push({ file, bytes })
4196
4637
  } catch (error) {
4197
4638
  failed += 1
4198
4639
  pushDetail(detail, 'object-missing ' + file.path + ' ' + errorDetail(error))
4199
4640
  }
4200
4641
  }
4201
- const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
4202
- // 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
4203
- // 清单先算全,超上限整套拒绝(绝不删一半)。
4204
- const plan = await this.planCreatedRemovals(payload, sink)
4642
+
4643
+ if (selectionSkipped > 0) {
4644
+ pushDetail(detail, 'selection-skipped=' + String(selectionSkipped) + ' selection=' + String(selection?.size ?? 0))
4645
+ }
4646
+ if (foreignSkipped > 0) {
4647
+ pushDetail(detail, 'foreign-skipped=' + String(foreignSkipped) + ' (records written by other sessions after this point; not overwritten)')
4648
+ }
4649
+ if (mergedCount > 0) pushDetail(detail, 'foreign-merged=' + String(mergedCount) + ' (reverted this session, kept the other side)')
4650
+ if (conflictCount > 0) pushDetail(detail, 'foreign-conflict=' + String(conflictCount) + ' (both sides changed the same place; needs a human)')
4651
+ if (unavailableCount > 0) pushDetail(detail, 'foreign-unavailable=' + String(unavailableCount) + ' (no safe way to revert; left untouched)')
4652
+ if (concurrentCount > 0) pushDetail(detail, 'concurrent-change=' + String(concurrentCount) + ' (changed again after the merge; left untouched)')
4205
4653
  const leftovers = {
4206
4654
  leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT),
4207
4655
  truncated: plan.truncated,
@@ -4209,9 +4657,34 @@ export class RollbackPointEngine {
4209
4657
  }
4210
4658
  // 用户 2026-10-11 定案 A:把「回退点之后新建」的路径也退回去(删除)。
4211
4659
  // removeCreated !== true ⇒ 只报告(paths 清空);判据仍是同一份 plan。
4212
- const removal = options.removeCreated === true
4660
+ // 归属保护同样作用于**删除集**(2026-10-17 跨对话安全回退):某路径若在留点之后被**别的会话**
4661
+ // 写过,删它同样是在销毁别人的工作 ⇒ 拒删(进 createdSkipped;预览与执行共用同一份规划,
4662
+ // 所以界面看到的「不会删」与执行端的「真的没删」仍然一致)。
4663
+ const removalPlan = options.removeCreated === true
4213
4664
  ? { paths: plan.paths, skipped: plan.skipped, truncated: plan.truncated, refused: plan.refused }
4214
4665
  : { paths: [] as string[], skipped: [] as string[], truncated: false, refused: null as CreatedRemovalPlan['refused'] }
4666
+ const protectedRemovals: string[] = []
4667
+ if (mergeGuardEnabled && removalPlan.paths.length > 0) {
4668
+ // 两类证据(任一成立就保护):① 归属表明别的会话写过它;② **别的会话的回合点里记录过该路径**。
4669
+ // 后者不依赖归属(归属可能因工具白名单 / 跨进程整体覆盖 / 采集时机而缺失,见 mergeGuardEnabled 的说明)。
4670
+ const foreignPaths = await this.foreignRecordedPaths(track.capturedAt, isolationSessionId)
4671
+ const keep: string[] = []
4672
+ for (const target of removalPlan.paths) {
4673
+ const writer = this.attribution?.writerOf(target)
4674
+ const byWriter = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > track.capturedAt
4675
+ if (byWriter || foreignPaths.has(pathKey(target))) {
4676
+ protectedRemovals.push(target)
4677
+ removalPlan.skipped.push(target)
4678
+ continue
4679
+ }
4680
+ keep.push(target)
4681
+ }
4682
+ removalPlan.paths = keep
4683
+ }
4684
+ const removal = removalPlan
4685
+ if (protectedRemovals.length > 0) {
4686
+ pushDetail(detail, 'foreign-removal-protected=' + String(protectedRemovals.length) + ' (written by other sessions; not deleted)')
4687
+ }
4215
4688
  if (removal.paths.length > ROLLBACK_MAX_CREATED_REMOVALS) {
4216
4689
  detail.push('created-removals=' + String(removal.paths.length) + ' limit=' + String(ROLLBACK_MAX_CREATED_REMOVALS) + ' zeroWrites=true')
4217
4690
  return {
@@ -4234,13 +4707,30 @@ export class RollbackPointEngine {
4234
4707
  }
4235
4708
  // E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
4236
4709
  // (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
4237
- const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
4710
+ const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array; sha256?: string; expectedCurrent?: string }[] = []
4238
4711
  for (const item of writable) {
4712
+ const targetSha = item.sha256 ?? item.file.sha256
4713
+ // 合并路径的**并发闸门**:写入前必须确认磁盘上仍是合并时读到的那一份
4714
+ if (item.expectedCurrent !== undefined) {
4715
+ let current: string | null = null
4716
+ try {
4717
+ current = sha256Hex(await fs.readFile(item.file.path))
4718
+ } catch {
4719
+ current = null
4720
+ }
4721
+ if (current !== item.expectedCurrent) {
4722
+ concurrentCount += 1
4723
+ if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) concurrentPaths.push(item.file.path)
4724
+ pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true')
4725
+ continue
4726
+ }
4727
+ }
4239
4728
  let identical = false
4240
4729
  try {
4241
4730
  const stat = await fs.stat(item.file.path)
4242
- if (stat.isFile() && stat.size === item.file.bytes) {
4243
- identical = sha256Hex(await fs.readFile(item.file.path)) === item.file.sha256
4731
+ // 长度比的是**这次要写进去的那份内容**(合并路径下它与记录里的原文长度不一定相同)
4732
+ if (stat.isFile() && stat.size === item.bytes.byteLength) {
4733
+ identical = sha256Hex(await fs.readFile(item.file.path)) === targetSha
4244
4734
  }
4245
4735
  } catch {
4246
4736
  identical = false
@@ -4260,6 +4750,12 @@ export class RollbackPointEngine {
4260
4750
  noop.createdSkippedCount = removal.skipped.length
4261
4751
  noop.createdSkippedTruncated = removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT
4262
4752
  if (removal.refused !== null) noop.createdRefusedReason = removal.refused
4753
+ if (selectionSkipped > 0) noop.selectionSkipped = selectionSkipped
4754
+ if (foreignSkipped > 0) noop.foreignSkipped = foreignSkipped
4755
+ if (mergedCount > 0) { noop.mergedCount = mergedCount; noop.mergedPaths = mergedPaths }
4756
+ if (conflictCount > 0) { noop.conflictCount = conflictCount; noop.conflictPaths = conflictPaths }
4757
+ if (unavailableCount > 0) { noop.unavailableCount = unavailableCount; noop.unavailablePaths = unavailablePaths }
4758
+ if (concurrentCount > 0) { noop.concurrentCount = concurrentCount; noop.concurrentPaths = concurrentPaths }
4263
4759
  return noop
4264
4760
  }
4265
4761
  // 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
@@ -4275,6 +4771,30 @@ export class RollbackPointEngine {
4275
4771
  leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
4276
4772
  }
4277
4773
  }
4774
+ // **最终并发校验**(紧邻写入之前):合并/预览之后文件可能又被改过。前一刻的 needed 判定与
4775
+ // 真正写入之间还隔着前像暂存,所以这里再确认一次;不符的条目直接摘掉 ——
4776
+ // 绝不覆盖比我们更新的内容。
4777
+ if (needed.some((item) => item.expectedCurrent !== undefined)) {
4778
+ const keep: typeof needed = []
4779
+ for (const item of needed) {
4780
+ if (item.expectedCurrent === undefined) { keep.push(item); continue }
4781
+ let current: string | null = null
4782
+ try {
4783
+ current = sha256Hex(await fs.readFile(item.file.path))
4784
+ } catch {
4785
+ current = null
4786
+ }
4787
+ if (current !== item.expectedCurrent) {
4788
+ concurrentCount += 1
4789
+ if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) concurrentPaths.push(item.file.path)
4790
+ pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true')
4791
+ continue
4792
+ }
4793
+ keep.push(item)
4794
+ }
4795
+ needed.length = 0
4796
+ needed.push(...keep)
4797
+ }
4278
4798
  const preCount = needed.length
4279
4799
  for (const item of needed) {
4280
4800
  let bytes: Uint8Array | null = null
@@ -4328,7 +4848,8 @@ export class RollbackPointEngine {
4328
4848
  for (const item of needed) {
4329
4849
  try {
4330
4850
  const current = await this.readFileBytes(item.file.path)
4331
- if (sha256Hex(current) !== item.file.sha256) mismatched.push(item.file.path)
4851
+ // 合并路径写入的不是记录里的原文,而是合并结果 ⇒ 复核必须比**这次真正写进去的那份**
4852
+ if (sha256Hex(current) !== (item.sha256 ?? item.file.sha256)) mismatched.push(item.file.path)
4332
4853
  } catch {
4333
4854
  mismatched.push(item.file.path)
4334
4855
  }
@@ -4411,6 +4932,12 @@ export class RollbackPointEngine {
4411
4932
  createdSkippedTruncated: removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT,
4412
4933
  writtenBytes,
4413
4934
  }
4935
+ if (selectionSkipped > 0) segment.selectionSkipped = selectionSkipped
4936
+ if (foreignSkipped > 0) segment.foreignSkipped = foreignSkipped
4937
+ if (mergedCount > 0) { segment.mergedCount = mergedCount; segment.mergedPaths = mergedPaths }
4938
+ if (conflictCount > 0) { segment.conflictCount = conflictCount; segment.conflictPaths = conflictPaths }
4939
+ if (unavailableCount > 0) { segment.unavailableCount = unavailableCount; segment.unavailablePaths = unavailablePaths }
4940
+ if (concurrentCount > 0) { segment.concurrentCount = concurrentCount; segment.concurrentPaths = concurrentPaths }
4414
4941
  // 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
4415
4942
  if (removal.refused !== null) segment.createdRefusedReason = removal.refused
4416
4943
  // 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
@@ -4646,6 +5173,11 @@ export interface RollbackTrackerOptions {
4646
5173
  log?: (line: string) => void
4647
5174
  /** 会话日志 stat 探针(轮询回退用) */
4648
5175
  statSessionLog?: (logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>
5176
+ /**
5177
+ * 写入归属采集器(2026-10-17 定案 B)。缺省 = 不采集 ⇒ 预览永远没有 foreign 标记,
5178
+ * 回退行为与旧版**逐字一致**。
5179
+ */
5180
+ attribution?: RollbackWriteRecorder
4649
5181
  }
4650
5182
 
4651
5183
  export interface RollbackTrackerResult {
@@ -4825,6 +5357,7 @@ export class RollbackAutoTracker {
4825
5357
  private readonly pollIntervalMs: number
4826
5358
  private readonly log: ((line: string) => void) | undefined
4827
5359
  private readonly statSessionLog: ((logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>) | undefined
5360
+ private readonly attribution: RollbackWriteRecorder | undefined
4828
5361
  private readonly bindings = new Map<string, RollbackSessionBinding>()
4829
5362
  private readonly open = new Map<string, { turn: number | null; startSeq: number; boundarySeq: number | null }>()
4830
5363
  /**
@@ -4849,6 +5382,7 @@ export class RollbackAutoTracker {
4849
5382
  )
4850
5383
  this.log = options.log
4851
5384
  this.statSessionLog = options.statSessionLog
5385
+ this.attribution = options.attribution
4852
5386
  }
4853
5387
 
4854
5388
  /** 实际采用的留点机制(output / 诊断用) */
@@ -4948,6 +5482,12 @@ export class RollbackAutoTracker {
4948
5482
  endSeq: null,
4949
5483
  })
4950
5484
  }
5485
+ if (event.type === 'tool/call') {
5486
+ // 写入归属(2026-10-17 定案 B):**只记,不改任何留点行为**,也不 await 落盘
5487
+ // (落盘由 attribution 自己 debounce;tool/call 洪峰不该在这里等 I/O)。
5488
+ this.attribution?.noteToolCall(event.sessionId, event.data)
5489
+ return { captured: false }
5490
+ }
4951
5491
  if (event.type !== 'turn/end') return { captured: false }
4952
5492
  // 回合结束只为「缺省边界」留个记号(轮询/无边界回退用它),不再拍点。
4953
5493
  this.open.delete(event.sessionId)