claude-code-session-sync 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,836 @@
1
+ """merge:memory 衝突的**安全保留兩版**(approach A)+ `memory-merge` 提示詞產生器。
2
+
3
+ 依據 DESIGN §7.1 / §7.3 / §9 + PLAN §2.9(memory 列)+ HANDOFF「先 A」:
4
+ - **偵測**三類 memory 衝突(沿用 `scan.build_plan` 的 memory 計畫):
5
+ `conflict-content`(同檔名兩側內容不同)/ `conflict-cross-file-identity`(同 frontmatter `name`
6
+ 落多檔名)/ `conflict-delete-vs-update`(一方刪除、另一方改過)。
7
+ - **安全保留兩版到 `memory/` 之外**(`$XDG_CACHE_HOME/claude-session-sync/merge/`,DESIGN §7.1):暫存區**不在**
8
+ `~/.claude/projects/<proj>/memory/`、**不在** hub → `list_memory_files` 掃不到、不會被當新 memory 同步擴散
9
+ (DoD §14:`.merge` 不外洩)。**只讀**正式 memory、**絕不**寫回 `memory/`(A3/§7.3:暫存清理由工具負責、
10
+ 不授權 AI 刪;合併寫回交使用者)。
11
+ - **`memory-merge` 提示詞產生器**:把兩版包成給 Claude 的合併提示詞。⚠ **明文外洩警告**(§7.3)——把兩版貼進
12
+ Claude 對話,prompt 會進 session JSONL → 下次 sync 同步到 hub = 敏感資訊從 memory 擴散進 transcript。故本
13
+ 工具**只**輸出到 stdout 或本機暫存(皆不同步)、**不自動餵 Claude**,並支援使用者先刪減敏感段(編輯暫存檔)。
14
+
15
+ **獨立指令、不併進例行 `sync`**(memory 的衝突/AI/洩漏流程不混進 session 同步)。真正 AI 合併 + 模糊近似比對留 P2。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import stat
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from urllib.parse import quote_from_bytes
27
+
28
+ from . import atomicio, memory, scan, tombstone
29
+ from .state import State
30
+
31
+ # 三類「需 memory-merge 處理」的衝突動作(皆非自動套用;apply 對它們只回報)。其餘 blocked-*/suppressed/local-deleted
32
+ # 是 fail-closed 或刪除閘,不在此(它們不是「兩版待合併」而是「不確定/已決」)。
33
+ CONFLICT_ACTIONS = frozenset({
34
+ "conflict-content", "conflict-cross-file-identity", "conflict-delete-vs-update",
35
+ })
36
+
37
+ # fuzzy(P2 Block B):「同事實、不同檔名」的**模糊近似**候選,經使用者**明確放行**後才轉成的衝突類型。
38
+ # **刻意不列入 CONFLICT_ACTIONS**(cardinal:fuzzy 分數永不進 classify/apply/sync/nudge——那些只認 CONFLICT_ACTIONS;
39
+ # `conflicts_from_plan` 只擷取 plan 動作,而 `classify_memory` 永遠不產 FUZZY_KIND)。FUZZY_KIND 的衝突**只**由
40
+ # `cli._cmd_memory_merge_fuzzy` 在 `--stage/--interactive` 放行後於記憶體建構、餵給 `stage_conflict`(與一般衝突共用
41
+ # 同一條 leak-safe 暫存路徑);plan/apply 這條路產不出它 → 結構上不可能自動保留/合併任何未放行的候選。
42
+ FUZZY_KIND = "conflict-fuzzy-identity"
43
+
44
+ META_FILE = "CONFLICT.json" # 暫存區每個衝突夾的 provenance/中繼資料(非 memory,不同步)
45
+ PROMPT_FILE = "PROMPT.md" # 產生的合併提示詞(本機暫存、不同步)
46
+ DONE_FILE = ".done" # 完成標記(**最後**寫);缺它=上次中途失敗的殘缺暫存(codex gate F3)
47
+ SCHEMA_VERSION = 1
48
+
49
+ _KIND_ZH = {
50
+ "conflict-content": "同檔名兩側內容不同",
51
+ "conflict-cross-file-identity": "同一 frontmatter name 出現在多個檔名",
52
+ "conflict-delete-vs-update": "一方刪除、另一方更新",
53
+ FUZZY_KIND: "疑似同一事實(模糊近似、不同檔名;使用者放行)",
54
+ }
55
+
56
+ # 明文外洩警告(§7.3):CLI 與 PROMPT.md 共用同一段,確保使用者每次都看到。
57
+ LEAK_WARNING = (
58
+ "⚠ 明文外洩警告:memory 是明文。若把下列內容貼進 Claude 對話來合併,該 prompt 會被寫進 session JSONL,\n"
59
+ " 下次 `sync` 就連同同步到 hub = 原本只在 memory 的敏感資訊擴散進 transcript(且 transcript 難以事後清除)。\n"
60
+ " → 本工具只把兩版保留到本機快取(memory/ 之外、不同步)或印到 stdout;**絕不自動餵給 Claude**。\n"
61
+ " → 合併前請自行刪減敏感段落(直接編輯暫存檔),並考慮在**不會被同步的拋棄式專案**裡進行合併。"
62
+ )
63
+
64
+
65
+ # ── 暫存區(memory/ 之外)─────────────────────────────────────────────────────
66
+
67
+ def merge_root() -> Path:
68
+ """衝突暫存根=`$XDG_CACHE_HOME/claude-session-sync/merge`(無 XDG → `~/.cache/...`,DESIGN §7.1)。
69
+ 刻意放 `memory/` 與 hub **之外**:`list_memory_files` 掃不到 → 不會被當新 memory 同步擴散(DoD:`.merge` 不外洩)。"""
70
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
71
+ return Path(base) / "claude-session-sync" / "merge"
72
+
73
+
74
+ _CTRL_RE = re.compile(r"[\x00-\x1f\x7f]")
75
+ _MAX_COMPONENT = 200 # 保守 < NAME_MAX(常見 255 bytes);percent-encode 後純 ASCII → len(out)==byte 數
76
+
77
+
78
+ def _disp(s: str | None) -> str:
79
+ """顯示用字串淨化:剔控制字元(含換行/CR/tab,POSIX 檔名可含 → 否則破壞單行/markdown 結構)+ 中和 lone
80
+ surrogate(非 UTF-8 檔名經 surrogateescape 解出 → 否則 `.encode("utf-8")` 在寫 PROMPT.md/CONFLICT.json 時
81
+ raise UnicodeEncodeError、令 memory-merge 崩潰,對稱 memory._index_title_text/Block 3c surrogate 洞)。**只用於
82
+ 顯示/中繼**——保留的原始 bytes(staged 版本檔)永遠原樣、不淨化。"""
83
+ if not s:
84
+ return s or ""
85
+ return _CTRL_RE.sub("", s).encode("utf-8", "replace").decode("utf-8")
86
+
87
+
88
+ def _key_disp(key: str | None) -> str:
89
+ """顯示用的衝突鍵。fuzzy 的 `key` 內部以 NUL 接兩檔名(`a\\x00b`,NUL 是唯一保證不出現在檔名的分隔符 → 分割
90
+ 單射);顯示時把 NUL 換成「 ↔ 」再過 `_disp`(一般 key 無 NUL、不受影響)——否則 `_disp` 逕自剔除控制字元會把
91
+ 兩檔名黏成一串。"""
92
+ return _disp((key or "").replace("\x00", " ↔ "))
93
+
94
+
95
+ def _fence_for(text: str) -> str:
96
+ """為內容選一條夠長的 code fence(≥4 個 `,且比內容中最長的連續 ` 還長 1)——memory 正文常含 ``` code
97
+ block,固定長度 fence 會被內容中的 ``` 提前關閉。CommonMark:關閉 fence 長度須 ≥ 開啟。"""
98
+ longest = cur = 0
99
+ for ch in text:
100
+ cur = cur + 1 if ch == "`" else 0
101
+ longest = max(longest, cur)
102
+ return "`" * max(4, longest + 1)
103
+
104
+
105
+ def _safe_component(s: str) -> str:
106
+ """把字串**injective**(一對一、可逆)轉成單一 FS-safe 路徑元件。percent-encode `os.fsencode(s)`(同
107
+ `memory._index_link_target`:RFC3986 unreserved `A-Za-z0-9-._~` 保留 → slug/檔名原樣可讀;空白/`/`/`:`/`?`/
108
+ `+`/控制字元/非 ASCII → %XX)。**必須 injective**(codex R1 High):`_` 取代式是 many-to-one——`a:b.md` 與
109
+ `a?b.md` 會撞同名,令第二個衝突被誤判 already-staged、或同組版本檔互蓋而靜默丟失。`os.fsencode` 還原非 UTF-8
110
+ 檔名的原始 bytes(surrogateescape)再逐 byte 編碼,永不破壞路徑或 raise。`.`/`..`/空(percent-encode 不碰 `.`)
111
+ 仍須擋路徑穿越 → 前綴 `_`(仍 injective:真實元件不會以 `_` 前綴恰好等於 `_.`/`_..`)。"""
112
+ out = quote_from_bytes(os.fsencode(s), safe="")
113
+ if not out:
114
+ out = "%" # 空輸入 sentinel(不應發生;"%" 非 quote 正常輸出 → 仍 injective)
115
+ elif set(out) <= {"."}: # ".", ".." → FS-special:逐點轉 %2E("%2E" 非 quote 正常輸出 → injective)
116
+ out = out.replace(".", "%2E")
117
+ if len(out) > _MAX_COMPONENT:
118
+ # percent-encode 對非 ASCII 檔名最多膨脹 3x → **合法** memory 檔名也可能超過 FS NAME_MAX、令
119
+ # atomic_create「file name too long」失敗而無法保留(codex R2 Medium)。截斷可讀前綴 + 全名 sha1
120
+ # (bounded 且仍 injective——不同原名 digest 不同;碰撞=sha1 部分碰撞,可忽略,同 codebase hash 信任)。
121
+ # 原始檔名完整存於 CONFLICT.json(`_disp(v.filename)`),故截斷夾名不需可逆。
122
+ digest = hashlib.sha1(os.fsencode(s)).hexdigest()[:20]
123
+ out = out[: _MAX_COMPONENT - len(digest) - 1] + "~" + digest
124
+ return out
125
+
126
+
127
+ def staging_dir(root: Path, conflict: "MemoryConflict") -> Path:
128
+ """某衝突的暫存夾(各段 sanitized)。content/delete-vs-update 用**檔名**鍵 → `<root>/<pk>/<filename>`;
129
+ cross-file-identity 用 **frontmatter identity** 鍵(slug,可能長得像檔名如 `notes.md`)→ 放獨立 `by-name/`
130
+ 子層 `<root>/<pk>/by-name/<identity>`。**分開命名空間**杜絕「identity=="x.md" 撞檔名 "x.md"」令兩個不同
131
+ 衝突共用同夾 → 第二個被當 already-staged 而靜默丟失(自審補洞)。"""
132
+ pk = _safe_component(conflict.project_key)
133
+ if conflict.kind == FUZZY_KIND:
134
+ # fuzzy 候選鍵=**一對**檔名(`a\x00b`,a≤b)→ 兩檔名各自 sanitize 成**獨立路徑段**放 `fuzzy/` 命名空間
135
+ # (`<root>/<pk>/fuzzy/<safe(a)>/<safe(b)>`)。兩層而非單層 join 才對 (a,b) **單射**(FS 分隔符保證分段,
136
+ # 單層 join 會因 `_safe_component` 保留 `_`/`.` 而可能撞名)。`.md` 保證檔名 sanitize 後含 `.` → 與字面
137
+ # `fuzzy`/`by-name` 命名空間永不撞(memory 檔名一律 `*.md`)。
138
+ a, _, b = conflict.key.partition("\x00")
139
+ return Path(root) / pk / "fuzzy" / _safe_component(a) / _safe_component(b)
140
+ leaf = _safe_component(conflict.key)
141
+ if conflict.kind == "conflict-cross-file-identity":
142
+ return Path(root) / pk / "by-name" / leaf
143
+ return Path(root) / pk / leaf
144
+
145
+
146
+ def _norm_parts(p: Path) -> list[str]:
147
+ """路徑各段的 **caseless + Unicode 正規化** 比對鍵(復用 `scan._name_key`=NFC∘casefold∘NFC)。
148
+
149
+ 供 case-/normalization-insensitive 的包含判定:macOS 預設 APFS **大小寫不敏感**、且對檔名做 Unicode 正規化,
150
+ 但 `PosixPath` 比對大小寫/正規化**敏感**、`resolve()` 又保留輸入拼寫 → 同一實體的不同拼寫(僅大小寫、或
151
+ NFC/NFD、或兩者)在 `==`/`is_relative_to` 下看似互不包含 → 暫存根「其實在 hub 內」卻漏判 → 明文兩版落進
152
+ 同步區外洩(mmfrom-g4 High)。逐段正規化後前綴比對可認出同一實體;case-sensitive FS 上「僅拼寫不同的相異
153
+ 夾」會被多判重疊(fail-closed,只多拒不外洩,與 cardinal DoD 同向)。與 memory 檔名別名判定共用同一正規化真相源。"""
154
+ return [scan._name_key(part) for part in Path(p).parts]
155
+
156
+
157
+ def _paths_overlap_ci(a: Path, b: Path) -> bool:
158
+ """a、b 任一等於或在另一之下(**caseless + 正規化不敏感**、逐段前綴)。輸入須已 `resolve()`(絕對、正規化)。
159
+
160
+ 等價於 `a==b or a.is_relative_to(b) or b.is_relative_to(a)`,但比對走 `_norm_parts`(見其 docstring 的 mmfrom-g4
161
+ 理由):對正規化後的兩段串,較短者為較長者前綴 ⟺ 兩路徑存在包含關係。"""
162
+ ap, bp = _norm_parts(a), _norm_parts(b)
163
+ n = min(len(ap), len(bp))
164
+ return ap[:n] == bp[:n]
165
+
166
+
167
+ def unsafe_staging_root(root: Path, forbidden: list[Path]) -> str | None:
168
+ """暫存根是否**不安全**(會破壞「memory/ 之外、不外洩」鐵則,codex R1 High)。回不安全原因或 None(安全)。
169
+
170
+ `XDG_CACHE_HOME` 是使用者環境變數、不可盲信:相對路徑(落在當前 cwd、不可預期)、或位於 hub / local 根
171
+ **之內**(→ 寫進受同步區 → 兩版被當新 memory 擴散)皆須 fail-closed 拒絕。`forbidden`=[hub, local_root]
172
+ (memory 夾恆在這兩根之下,故涵蓋所有 memory/ 樹)。兩邊都 `resolve()`(跟隨 symlink → 擋「root 被 symlink
173
+ 進 hub」)後比對;root==forbidden 或 root 在 forbidden 之下 → 不安全。resolve 失敗 → fail-closed 視為不安全。"""
174
+ if not Path(root).is_absolute():
175
+ return f"暫存根非絕對路徑(XDG_CACHE_HOME 相對路徑不可預期):{root}"
176
+ try:
177
+ rr = Path(root).resolve()
178
+ except OSError as e:
179
+ return f"暫存根無法解析(保守視為不安全):{e}"
180
+ for f in forbidden:
181
+ try:
182
+ fr = Path(f).resolve()
183
+ except OSError as e:
184
+ # **fail-closed**(不 continue):解析不了某受同步樹就無法**證明**暫存不在其內 → 保守拒絕(mmfrom-g3
185
+ # High)。非存在/離線路徑走 resolve(strict=False) 不 raise(僅 symlink-loop/權限/exotic FS 才 raise)→
186
+ # 尋常未掛載 remote 不會誤拒;真的 raise=FS 異常,寧可拒也不放行可能重疊的寫入。
187
+ return f"受同步區路徑無法解析({f})→ 無法證明暫存不重疊,保守拒絕:{e}"
188
+ # **雙向**重疊都拒(gate7 F1 High):root 在 hub/local 內 → 暫存直接落同步區;**或** hub/local 在 root
189
+ # **內** → 某 per-conflict dest(`<root>/<pk>/<key>`)可能正好落進 hub/local(如 hub==root/projA)→ 一樣外洩。
190
+ # 數學上:若 root 與 forbidden 互不包含,則 root 底下任何 dest 都不可能在 forbidden 內(共同祖先須是其一)。
191
+ # 比對走 **caseless + 正規化不敏感**(mmfrom-g4 High):macOS 預設 APFS 大小寫不敏感 + resolve 保留拼寫 →
192
+ # `is_relative_to` 大小寫敏感會漏判「同一實體不同拼寫」的重疊 → 暫存落進 hub 外洩。見 `_paths_overlap_ci`。
193
+ if _paths_overlap_ci(rr, fr):
194
+ return (f"暫存根與受同步區({fr})重疊 → 兩版可能落進同步區被當新 memory 擴散;"
195
+ "請把 XDG_CACHE_HOME 設到 hub/local 之外(互不包含)。")
196
+ return None
197
+
198
+
199
+ # ── 資料模型 ─────────────────────────────────────────────────────────────────
200
+
201
+ @dataclass(frozen=True)
202
+ class ConflictVersion:
203
+ """衝突中的一個版本。真實 memory 版本帶 `data`/`text`(已**一次讀入**,避免 stage 時 re-read 的 TOCTOU 與
204
+ hash 漂移);`is_tombstone` 版本只帶刪除標記中繼(無檔內容)。`label`=來源側(`local`/`hub`/`local+hub`,
205
+ 或 `tombstone`)。"""
206
+
207
+ label: str
208
+ filename: str
209
+ content_hash: str | None
210
+ text: str | None = None
211
+ data: bytes | None = None
212
+ is_tombstone: bool = False
213
+ base_hash: str | None = None
214
+ identity: str | None = None
215
+ machine: str | None = None
216
+ time: str | None = None
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class MemoryConflict:
221
+ project_key: str
222
+ kind: str # CONFLICT_ACTIONS 之一
223
+ key: str # 暫存夾名基底(檔名或 identity)
224
+ versions: tuple[ConflictVersion, ...]
225
+ reason: str
226
+ notes: tuple[str, ...] = () # 退化警告(plan 後某側讀不到→保留不完整,codex gate F2);非空 → CLI 非零提醒重跑。
227
+
228
+ def staged_versions(self) -> list[ConflictVersion]:
229
+ """有實體內容、會落成暫存檔的版本(排除 tombstone 標記與讀不到內容者)。"""
230
+ return [v for v in self.versions if not v.is_tombstone and v.data is not None]
231
+
232
+
233
+ @dataclass
234
+ class StageResult:
235
+ conflict: MemoryConflict
236
+ dest: Path
237
+ status: str # would-stage | staged | already-staged | degraded | incomplete | stale | error | empty
238
+ files: list[str] = field(default_factory=list)
239
+ notes: list[str] = field(default_factory=list)
240
+
241
+
242
+ # ── 讀取(no-follow,只讀正式 memory)──────────────────────────────────────────
243
+
244
+ def _read_nofollow(mdir: Path, filename: str) -> bytes | None:
245
+ """讀 `mdir/filename` 的 bytes,**不跟隨 symlink、不卡在 FIFO/device**(對稱 apply 的索引讀)。
246
+
247
+ **父夾 + 最終元件雙重 no-follow**(codex R1 Medium + gate2 High):O_NOFOLLOW 只擋**最終**元件,且
248
+ **Windows 無 O_NOFOLLOW**(`getattr(...,0)`=0)→ 完全不擋。故對**父夾與最終檔都先 `is_symlink` 明確
249
+ lstat**(no-throw,cross-OS;ENOENT/權限/ELOOP 一律當不安全 → None),再開最終元件加 O_NOFOLLOW(POSIX
250
+ 再保險)+ O_NONBLOCK + fstat `S_ISREG`。否則 `build_plan` 後某側檔/`memory/` 根被換成 symlink → 跟隨讀到
251
+ 夾外檔 → 複製進快取/提示詞外洩(Windows 尤其,因 O_NOFOLLOW 失效)。**有界殘留**:lstat 與 os.open 間的
252
+ µs 窗(同 Block 3c 父夾 symlink,受非對抗模型約束、不上 POSIX-only dir_fd;此處只讀進本機快取、危害更小)。
253
+ 缺檔/讀錯/非普通檔/symlink 一律 None(呼叫端略過該版本、記 note;保留是便利性、不為它崩)。"""
254
+ p = Path(mdir) / filename
255
+ try:
256
+ if Path(mdir).is_symlink() or p.is_symlink(): # 父夾或最終檔為 symlink → fail-closed(cross-OS lstat)
257
+ return None
258
+ except OSError:
259
+ return None
260
+ flags = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
261
+ | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
262
+ try:
263
+ fd = os.open(p, flags)
264
+ except OSError:
265
+ return None
266
+ try:
267
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
268
+ return None
269
+ chunks: list[bytes] = []
270
+ while True:
271
+ chunk = os.read(fd, 1 << 20)
272
+ if not chunk:
273
+ break
274
+ chunks.append(chunk)
275
+ return b"".join(chunks)
276
+ except OSError:
277
+ return None
278
+ finally:
279
+ os.close(fd)
280
+
281
+
282
+ def _collect_file_versions_sides(filename: str,
283
+ sides: list[tuple[str, Path | None]]) -> list[ConflictVersion]:
284
+ """讀某檔名在**給定各側**的內容,**依正規化 content_hash 去重**(不同側位元雖異但正規化相同 → 合成單一
285
+ `a+b` 版本,避免假兩版)。damaged(hash=None)每側獨立保留(無法證明相同)。每側只讀一次;`mdir=None` 的側
286
+ → 跳過(不讀、不崩)。`sides`=`[(label, mdir), ...]`(label 進版本 label);session/cross-file 衝突固定
287
+ local+hub 兩側,fuzzy 依「**列出時該檔實際出現的側**」限定(見 `fuzzy_conflict`)。"""
288
+ by_hash: dict[str, list] = {} # hash_key -> [data, text, [sides], content_hash]
289
+ order: list[str] = []
290
+ for label, mdir in sides:
291
+ if mdir is None:
292
+ continue
293
+ data = _read_nofollow(mdir, filename)
294
+ if data is None:
295
+ continue
296
+ doc = memory.load_memory_bytes(data)
297
+ h = memory.content_hash(doc)
298
+ key = h if h is not None else f"\x00damaged:{label}" # damaged 不合併、每側獨立
299
+ if key in by_hash:
300
+ by_hash[key][2].append(label)
301
+ else:
302
+ by_hash[key] = [data, doc.text, [label], h]
303
+ order.append(key)
304
+ out: list[ConflictVersion] = []
305
+ for key in order:
306
+ data, text, side_lbls, h = by_hash[key]
307
+ out.append(ConflictVersion(label="+".join(side_lbls), filename=filename,
308
+ content_hash=h, text=text, data=data))
309
+ return out
310
+
311
+
312
+ def _collect_file_versions(filename: str, local_mdir: Path | None,
313
+ hub_mdir: Path | None) -> list[ConflictVersion]:
314
+ """讀某檔名在 local/hub 兩側(session/cross-file 衝突用;`conflicts_from_plan`/`_cross_file_conflicts` 呼叫)。
315
+ 薄包裝 `_collect_file_versions_sides`(單一讀取/去重真相源;行為與改動前逐位元組一致)。"""
316
+ return _collect_file_versions_sides(filename, [("local", local_mdir), ("hub", hub_mdir)])
317
+
318
+
319
+ def _stage_safe_mdir(mdir: Path | None) -> Path | None:
320
+ """stage-time TOCTOU 重驗(e2e gate2 #3 同類;R1 High):list→stage 間,memory/ **之上的專案夾**可能被換成
321
+ symlink/junction **逃逸信任根** → 經 memory/ 讀到界外 bytes(`_read_nofollow` 只守 memory/ 夾與 leaf、**不**守其上
322
+ 的專案夾;junction 在 Windows 非 symlink → `mdir` 自身 lstat 也認不出)→ 界外明文被保留進快取/PROMPT = 外洩。
323
+ 故讀前重驗**專案夾**(=mdir 父夾,root=再上一層):逃逸 → 回 None(該側視為缺 → 讀不到 → degraded note)。與
324
+ `conflicts_from_plan` 讀前 `_safe_project_dir` 重驗兩側專案夾**對稱**(單一真相源 `scan._safe_project_dir`)。
325
+
326
+ **範圍界定(重要)**:本檢查守的是**專案夾**逃逸。`memory/` 夾本身若是 **directory junction** 則**刻意跟隨**
327
+ (`_read_nofollow` 只擋 symlink、不擋 junction)——這是既定的 CLAUDE_CONFIG_DIR/ccdir 政策(2026-06-30 使用者拍板):
328
+ memory/ junction=使用者刻意的**同機多帳號共用**(方式1),`list_memory_files` 與現存 `conflicts_from_plan` 讀取
329
+ 路徑一律跟隨、與此對稱。故「掃描時真實 memory/ 於放行後被換成指向界外的 junction」屬 out-of-model 有界 TOCTOU
330
+ 殘留(持久 junction=合法共用;換掉=非對抗模型外),與 exact memory-merge 同立場,不在此另擋(否則會破壞方式1)。"""
331
+ if mdir is None:
332
+ return None
333
+ proj = Path(mdir).parent
334
+ return mdir if scan._safe_project_dir(proj.parent, proj) else None
335
+
336
+
337
+ def _revalidate_sides(sides: list[tuple[str, Path | None]]) -> list[tuple[str, Path | None]]:
338
+ """讀前重驗每側**專案夾**(`_stage_safe_mdir`);逃逸側其 mdir → None(→ 讀不到 → degraded),label 保留供診斷。"""
339
+ return [(label, _stage_safe_mdir(mdir)) for label, mdir in sides]
340
+
341
+
342
+ def fuzzy_conflict(project_key: str, a: str, a_sides: list, b: str, b_sides: list,
343
+ *, reason: str) -> MemoryConflict:
344
+ """把一對**使用者已放行**的模糊候選(兩個不同檔名,呼叫端保證 `a ≤ b`)讀成 `MemoryConflict`(kind=FUZZY_KIND)
345
+ 供 `stage_conflict` 保留兩版。**每檔只從其計分來源讀**(`a_sides`/`b_sides`=`[(label, mdir), ...]`,CLI 綁**單一
346
+ 計分側**——見 `_run_fuzzy_stage`/`score_src`)——**不**回退/probe 另一側的同名檔(g2 High:否則放行後計分側被刪、
347
+ 另一側剛好有**無關**同名檔 → 靜默保留錯內容並標記完成=靜默替換)。讀前重驗專案夾(`_revalidate_sides`→
348
+ `_stage_safe_mdir`,防專案夾逃逸讀界外,R1 High);同一條 no-follow leak-safe 讀;只讀正式 memory、絕不寫回。
349
+
350
+ **只在使用者放行後呼叫**(`--stage` 全部 / `--interactive` 逐對)——本函式不做放行判斷(cardinal:放行是使用者
351
+ 的、不是分數的)。某檔於放行後其計分來源讀不到(刪除/改名/專案夾逃逸/來源缺)→ 退化 note → `stage_conflict` 不寫
352
+ `.done`、CLI 非零、提示重跑(絕不靜默把「只剩一檔」或「別側無關同名檔」當完整)。key=`a\\x00b`(見 `staging_dir`)。"""
353
+ va = _collect_file_versions_sides(a, _revalidate_sides(a_sides))
354
+ vb = _collect_file_versions_sides(b, _revalidate_sides(b_sides))
355
+ missing = [fn for fn, vs in ((a, va), (b, vb)) if not vs]
356
+ notes = ((f"以下檔於列出後讀不到、保留不完整(請重跑):{', '.join(missing)}",) if missing else ())
357
+ return MemoryConflict(project_key, FUZZY_KIND, f"{a}\x00{b}", tuple(va + vb), reason, notes)
358
+
359
+
360
+ def _both_side_identities(filename: str, local_mdir: Path, hub_mdir: Path) -> set[str]:
361
+ """某檔名在**兩側**的 frontmatter `name` slug 集(兩側各取一次)。cross-file 歸組須看兩側——同檔名兩側
362
+ name 可能不同(一側改名、一側未改),只看先讀到的一側會把該檔錯歸、漏掉真正的合併群(codex R1 Medium)。"""
363
+ out: set[str] = set()
364
+ for mdir in (local_mdir, hub_mdir):
365
+ data = _read_nofollow(mdir, filename)
366
+ if data is not None:
367
+ nm = memory.load_memory_bytes(data).name
368
+ if nm:
369
+ out.add(nm)
370
+ return out
371
+
372
+
373
+ def _tombstone_versions(name: str, hub_dir: Path,
374
+ versions: list[ConflictVersion]) -> list[ConflictVersion]:
375
+ """delete-vs-update 的**所有**相關刪除標記版本(codex R1 Medium:不可只取第一個)。同檔名 memory tombstone
376
+ + 所有 identity 命中現存版本 frontmatter name 的**別檔名** memory tombstone(多次換檔名刪除、tombstone 永不
377
+ GC → 真實可能有多筆)。全部附上,否則提示詞會藏掉某些刪除/base hash → 使用者誤判。依 target 去重、排序求決定性。"""
378
+ out: list[ConflictVersion] = []
379
+ seen: set[str] = set()
380
+
381
+ def _add(tb) -> None:
382
+ if tb.target in seen:
383
+ return
384
+ seen.add(tb.target)
385
+ out.append(ConflictVersion(label="tombstone", filename=tb.target, content_hash=None,
386
+ is_tombstone=True, base_hash=tb.base_hash, identity=tb.identity,
387
+ machine=tb.machine, time=tb.time))
388
+
389
+ direct = tombstone.find_memory_tombstone(hub_dir, name)
390
+ if direct is not None:
391
+ _add(direct)
392
+ idents = {nm for v in versions if v.data is not None
393
+ for nm in [memory.load_memory_bytes(v.data).name] if nm}
394
+ if idents:
395
+ for (k, _t), tb in sorted(tombstone.read_tombstones(hub_dir).items()):
396
+ if k == "memory" and tb.identity in idents and tb.target != name:
397
+ _add(tb)
398
+ return out
399
+
400
+
401
+ def _cross_file_conflicts(pk: str, entries: list, local_mdir: Path,
402
+ hub_mdir: Path) -> list[MemoryConflict]:
403
+ """把 cross-file-identity 的逐檔 plan 條目歸組成衝突(同一事實拆成多檔 → 一起合併)。
404
+
405
+ **連通分量歸組**(codex R1 Medium):以「檔名」為節點、共享任一 frontmatter `name`(兩側皆計)為邊,做
406
+ union-find;每個連通分量=一個衝突。能正確處理「a.md 在 local 是 xname、在 hub 是 yname,b.md 是 yname」
407
+ 這種跨側分歧——a.md 與 b.md 應同組(只看單側會把 yname 的合併群拆散、漏檔)。identity 全不可判的檔自成
408
+ 一組(以檔名為鍵)。"""
409
+ ids: dict[str, set[str]] = {m.name: _both_side_identities(m.name, local_mdir, hub_mdir)
410
+ for m in entries}
411
+ reason = {m.name: m.reason for m in entries}
412
+ parent: dict[str, str] = {fn: fn for fn in ids}
413
+
414
+ def find(x: str) -> str:
415
+ while parent[x] != x:
416
+ parent[x] = parent[parent[x]]
417
+ x = parent[x]
418
+ return x
419
+
420
+ by_ident: dict[str, str] = {}
421
+ for fn, fids in ids.items():
422
+ for i in fids:
423
+ if i in by_ident:
424
+ parent[find(fn)] = find(by_ident[i])
425
+ else:
426
+ by_ident[i] = fn
427
+ comps: dict[str, list[str]] = {}
428
+ for fn in ids:
429
+ comps.setdefault(find(fn), []).append(fn)
430
+ # **專案級退化偵測**(gate3 F1 + gate4 F1,silent-loss 防護)。merge 由**現況**重建分組,可能與 plan 的 T0
431
+ # 快照分歧;分歧時倖存成員的組會看似「完整」被寫 `.done` → 下次 already-staged 卡住缺版本。兩個 plan 不變量
432
+ # 用來**偵測分歧**(無須把整組塞進 MemoryPlan):
433
+ # ① plan 只在某成員「規劃時可讀」才標 cross-file → 此刻讀不到(`missing`)=分歧;
434
+ # ② plan 只在某檔 frontmatter name **與別檔共享**(≥2 檔)才標 cross-file → 故每個 cross-file 成員此刻
435
+ # **至少應與另一檔同組**;若任一連通分量是**單檔**(singleton)=其同名夥伴的 name 已改/消失(gate4 F1,
436
+ # 改名而非讀不到的分歧),分組已裂。
437
+ # 任一分歧 → **整個專案的 cross-file 組全部退化**(無法得知裂出成員原屬哪組):不寫 .done、CLI 非零、提示重跑;
438
+ # 下次現況與 plan 一致(成員可讀、分組復原)才落 .done。
439
+ collected: list[tuple[str, list[str], list[ConflictVersion], list[str]]] = []
440
+ project_diverged = False
441
+ for fnames in comps.values():
442
+ fnames = sorted(fnames)
443
+ comp_ids = sorted({i for fn in fnames for i in ids[fn]})
444
+ key = comp_ids[0] if comp_ids else fnames[0] # identity 全不可判 → 退回檔名
445
+ if len(fnames) < 2: # singleton=同名夥伴已改名/消失(plan 保證 ≥2)→ 分歧(gate4 F1)
446
+ project_diverged = True
447
+ versions: list[ConflictVersion] = []
448
+ missing: list[str] = []
449
+ for fn in fnames:
450
+ vs = _collect_file_versions(fn, local_mdir, hub_mdir)
451
+ if not vs:
452
+ missing.append(fn) # 規劃時在、此刻讀不到(symlink 抽換/刪除)→ 分歧(gate3 F1)
453
+ project_diverged = True
454
+ versions.extend(vs)
455
+ collected.append((key, fnames, versions, missing))
456
+ out: list[MemoryConflict] = []
457
+ for key, fnames, versions, missing in collected:
458
+ notes: tuple[str, ...] = ()
459
+ if missing:
460
+ notes = (f"以下檔於規劃後讀不到、保留不完整(請重跑 sync):{', '.join(missing)}",)
461
+ elif project_diverged:
462
+ notes = ("本專案跨檔分組於規劃後分歧(成員讀不到或已改名)、分組可能不完整(請重跑 sync)",)
463
+ if versions or notes: # 有版本或有退化警告都 emit——**絕不靜默丟**(gate F1/F2)
464
+ out.append(MemoryConflict(pk, "conflict-cross-file-identity", key, tuple(versions),
465
+ reason[fnames[0]], notes))
466
+ return sorted(out, key=lambda c: c.key)
467
+
468
+
469
+ # ── 偵測 ─────────────────────────────────────────────────────────────────────
470
+
471
+ def conflicts_from_plan(plan: scan.SyncPlan, *, project: str | None = None) -> list[MemoryConflict]:
472
+ """從**已建好的** SyncPlan 抽 memory 衝突(純擷取、不重建 plan)。CLI 先 build_plan → 檢查 halt(掛錯碟等
473
+ 異常 surface),再呼叫本函式;測試走 `find_conflicts` 便利包裝。只看**兩側皆綁定**的專案——衝突(content/
474
+ cross-file/delete-vs-update)唯有 hub+local 都在時才產生(單邊是 copy/blocked,不是衝突)。"""
475
+ out: list[MemoryConflict] = []
476
+ for pp in plan.projects:
477
+ if not (pp.local_dir and pp.hub_dir):
478
+ continue
479
+ pk = Path(pp.hub_dir).name
480
+ if project is not None and pk != project:
481
+ continue
482
+ # 逃逸重驗(TOCTOU:build_plan 後專案夾被換成逃逸 junction)→ 不從界外讀 memory 進暫存/prompt(e2e gate2 #3)。
483
+ # `_read_nofollow` 只守 memory/ 夾與最終檔、**不**守其上的專案夾 junction,故在此重驗專案夾(單一真相源)。
484
+ ldir, hdir = Path(pp.local_dir), Path(pp.hub_dir)
485
+ if not scan._safe_project_dir(ldir.parent, ldir) or not scan._safe_project_dir(hdir.parent, hdir):
486
+ continue
487
+ conf = [m for m in pp.memories if m.action in CONFLICT_ACTIONS]
488
+ if not conf:
489
+ continue
490
+ local_mdir = memory.memory_dir(pp.local_dir)
491
+ hub_mdir = memory.memory_dir(pp.hub_dir)
492
+ cross = [m for m in conf if m.action == "conflict-cross-file-identity"]
493
+ out.extend(_cross_file_conflicts(pk, cross, local_mdir, hub_mdir))
494
+ for m in conf:
495
+ if m.action == "conflict-cross-file-identity":
496
+ continue
497
+ versions = list(_collect_file_versions(m.name, local_mdir, hub_mdir))
498
+ real = [v for v in versions if not v.is_tombstone]
499
+ notes: list[str] = []
500
+ if m.action == "conflict-delete-vs-update":
501
+ tvs = _tombstone_versions(m.name, Path(pp.hub_dir), versions)
502
+ versions.extend(tvs)
503
+ if not real: # delete-vs-update 規劃時必有現存版本;此刻讀不到 → 退化(gate F2)
504
+ notes.append("現存版本於規劃後讀不到、保留不完整(請重跑 sync)")
505
+ if not tvs: # delete-vs-update 規劃時必有 tombstone;此刻 re-discover 不到(現存檔改名/改寫令
506
+ # identity 不再命中已刪 identity tombstone,gate4 F2)→ 刪除側會被靜默漏掉 → 退化、
507
+ # 不寫 .done、不靜默把「只剩現存內容」當完整(merge 由現況 re-discover、非信 plan 的殘留)。
508
+ notes.append("刪除標記於規劃後對不上(現存檔疑改名/改寫)、刪除側可能漏掉、保留不完整(請重跑 sync)")
509
+ elif len(real) < 2: # conflict-content 規劃時兩側皆在且相異;不足兩版 → 某側讀不到/已變(gate F2)
510
+ notes.append("預期兩側內容、但某側於規劃後讀不到或已變、保留不完整(請重跑 sync)")
511
+ # **絕不靜默丟**:即使 versions 空(全讀不到)也 emit(帶退化 note),由 stage 回 empty + 警告。
512
+ out.append(MemoryConflict(pk, m.action, m.name, tuple(versions), m.reason, tuple(notes)))
513
+ return out
514
+
515
+
516
+ def find_conflicts(local_root, hub_root, state: State | None, *, project: str | None = None,
517
+ identity_fn=None) -> list[MemoryConflict]:
518
+ """便利包裝:build_plan + `conflicts_from_plan`(halt 時 plan.projects 為空 → 回 [];CLI 另行 surface halt)。"""
519
+ plan = scan.build_plan(local_root, hub_root, state, identity_fn=identity_fn)
520
+ return conflicts_from_plan(plan, project=project)
521
+
522
+
523
+ def unscannable_memory_projects(plan: scan.SyncPlan, *, project: str | None = None) -> list[str]:
524
+ """memory **無法掃描**的兩側皆綁定專案(`memory/` 根 symlink/不可讀)。供 CLI surface——否則 memory 被跳過時
525
+ memory-merge 會把「沒掃到」誤報成「無衝突」並回 0(gate2 F3)。回 `"<pk>(…)"` 字串清單。
526
+
527
+ **以 plan 結構化旗標 `memory_scan_failed` 為準**(gate4 F3):plan 在 T0 記錄了「這專案 memory 沒掃」,**不可**
528
+ 被此刻 FS recheck 成功(transient 失敗已恢復)抹掉——否則就用一份「漏掃 memory」的 plan 回報無衝突。recheck
529
+ (`list_memory_files`)只是**補充**目前仍不可掃者,與旗標**聯集**。"""
530
+ out: list[str] = []
531
+ for pp in plan.projects:
532
+ if not (pp.local_dir and pp.hub_dir):
533
+ continue
534
+ pk = Path(pp.hub_dir).name
535
+ if project is not None and pk != project:
536
+ continue
537
+ if pp.memory_scan_failed: # 信任 plan 的 T0 跳過事實(即使現在 recheck 會成功)
538
+ out.append(f"{pk}(規劃時 memory 未掃描)")
539
+ for side, d in (("local", pp.local_dir), ("hub", pp.hub_dir)): # 補充:目前仍不可掃者
540
+ if not scan._safe_project_dir(Path(d).parent, Path(d)): # 專案夾逃逸 → 不讀界外(e2e gate2 #3)
541
+ out.append(f"{pk}({side}:專案夾為 symlink/逃逸信任根)")
542
+ continue
543
+ try:
544
+ memory.list_memory_files(memory.memory_dir(d))
545
+ except memory.UnsafeMemoryDir:
546
+ out.append(f"{pk}({side}:memory/ 根為 symlink)")
547
+ except OSError as e:
548
+ out.append(f"{pk}({side}:memory 夾讀取失敗 {e.__class__.__name__})")
549
+ return out
550
+
551
+
552
+ # ── 保留兩版(暫存,approach A)────────────────────────────────────────────────
553
+
554
+ def planned_staged_names(conflict: MemoryConflict) -> list[str]:
555
+ """預覽:此衝突會落成哪些暫存檔名(`<label>__<filename>`,sanitized)。"""
556
+ return [_safe_component(f"{v.label}__{v.filename}") for v in conflict.staged_versions()]
557
+
558
+
559
+ def _conflict_fingerprint(conflict: MemoryConflict) -> str:
560
+ """衝突**本質內容**的決定性指紋(project_key + kind + key + 各版本 (filename, 內容指紋) + 各 tombstone (target,
561
+ base, identity))。供 `.done` 暫存的**陳舊偵測**(gate5 F1):同一檔名鍵的衝突會隨時間**換 kind 或換內容**(content
562
+ →delete-vs-update、或兩側被改成新內容),若只看 .done 就回 already-staged,會用舊證據遮蓋新衝突。指紋變→stale。
563
+
564
+ 版本內容指紋:有正規化 `content_hash` 用它;**damaged(content_hash=None,如 delete-vs-update 的損壞現存側)
565
+ 退回 raw bytes 的 sha256**(gate6 F1)——否則兩段不同的損壞 bytes 都成 `None`、指紋不變 → 換內容卻誤判
566
+ already-staged。排序**已解析的條目字串**(含 raw sha)求決定性。surrogatepass 容非 UTF-8 檔名不崩。"""
567
+ items: list[str] = []
568
+ for v in conflict.versions:
569
+ if v.is_tombstone:
570
+ items.append(f"T:{v.filename}:{v.base_hash}:{v.identity}")
571
+ elif v.content_hash:
572
+ items.append(f"V:{v.filename}:{v.content_hash}")
573
+ elif v.data is not None:
574
+ items.append(f"V:{v.filename}:raw:{hashlib.sha256(v.data).hexdigest()}")
575
+ else:
576
+ items.append(f"V:{v.filename}:None")
577
+ # project_key 納入(e2e-g2):暫存夾 <merge>/<pk>/… 理應 per-pk 隔離,但兩個大小寫/正規化折疊後相同的相異 pk 在
578
+ # 不敏感的快取 FS 上會撞成同一實體夾 → 若指紋省 pk,不同專案同檔名/內容的衝突會誤判 already-staged 靜默略過(同一
579
+ # pk 內 pk 為常量、same/stale 判定不變;跨 pk 撞夾時才生效區分)。fuzzy 端另有 pk 折疊護欄先擋,這是共用層縱深防禦。
580
+ parts = [f"pk:{conflict.project_key}", f"kind:{conflict.kind}", f"key:{conflict.key}", *sorted(items)]
581
+ return hashlib.sha256("\n".join(parts).encode("utf-8", "surrogatepass")).hexdigest()
582
+
583
+
584
+ def _conflict_meta(conflict: MemoryConflict, staged: dict[int, str]) -> dict:
585
+ # 顯示/中繼欄位一律過 _disp(檔名可含控制字元/surrogate → 否則 json.dumps→encode 崩潰)。hash 為 hex、安全。
586
+ versions = []
587
+ for i, v in enumerate(conflict.versions):
588
+ versions.append({
589
+ "label": _disp(v.label), "filename": _disp(v.filename), "content_hash": v.content_hash,
590
+ "is_tombstone": v.is_tombstone, "staged_file": staged.get(i),
591
+ "base_hash": v.base_hash, "identity": _disp(v.identity),
592
+ "machine": _disp(v.machine), "time": _disp(v.time),
593
+ })
594
+ return {
595
+ "schema_version": SCHEMA_VERSION, "project_key": _disp(conflict.project_key),
596
+ "kind": conflict.kind, "key": _key_disp(conflict.key), "reason": _disp(conflict.reason),
597
+ "staged_time": tombstone.now_iso(),
598
+ "fingerprint": _conflict_fingerprint(conflict), # 陳舊偵測基準(gate5 F1)
599
+ "complete": not conflict.notes, # 退化(某側讀不到)→ 不完整(gate2 F1)
600
+ "notes": [_disp(n) for n in conflict.notes], # 退化警告持久化進中繼(gate2 F1)
601
+ "versions": versions,
602
+ }
603
+
604
+
605
+ def _completed_match(dest: Path, conflict: MemoryConflict) -> str | None:
606
+ """leaf 已完成(有 `.done`)時,比對暫存的 fingerprint 與**目前**衝突(gate5 F1)。回 `same`(同一衝突 →
607
+ already-staged 幂等)/ `stale`(衝突已換 kind/內容 → 暫存證據過時,不可當已處理)/ None(無 `.done` → 未完成)。
608
+ `.done` 在但 CONFLICT.json 讀不到/壞 → 保守當 `stale`(無法確認 → 不沿用舊暫存)。"""
609
+ if not os.path.exists(atomicio.os_path(dest / DONE_FILE)): # os_path:深 staging 路徑長路徑安全
610
+ return None
611
+ try:
612
+ stored = json.loads(atomicio.read_text(dest / META_FILE)).get("fingerprint")
613
+ except (OSError, ValueError):
614
+ return "stale"
615
+ return "same" if stored == _conflict_fingerprint(conflict) else "stale"
616
+
617
+
618
+ class UnsafeStagingPath(OSError):
619
+ """暫存路徑中(root→dest 任一層)含 symlink/junction/reparse → 拒絕(防寫入被重導進 hub/memory,codex gate F1 + e2e Pass1)。"""
620
+
621
+
622
+ def _claim_staging_dir(root: Path, dest: Path) -> str:
623
+ """**逐層 no-follow** 建立 `root→dest`,回 `claimed`(本次新建 leaf)/`already-staged`(leaf 已是真實夾)。
624
+ 任一層為 symlink/**junction**/reparse/非目錄 → `raise UnsafeStagingPath`(junction 在 Windows 非 symlink、
625
+ 須以 `reparse_kind` 才擋得到,e2e Pass1 High)。
626
+
627
+ 為何必要(codex gate F1 High):`unsafe_staging_root` 只驗**根**;但 `<root>/<pk>/<key>` 的**每層子路徑**若有
628
+ 既存 **symlink**(如 `merge/<proj>` → hub/memory),`mkdir(parents=True)` + 後續寫入會**跟隨它**把兩版/PROMPT.md
629
+ 寫進受同步區 → 外洩。故 root 以下**逐層** `os.mkdir` + `lstat` 驗證皆真實目錄、非 symlink。`root` 本身及其祖先
630
+ 鏈已由 CLI `unsafe_staging_root` 的 `resolve()`(跟隨所有 symlink)驗在 hub/local 之外,故 root 用 `mkdir(parents)`
631
+ 建即可(不在此 no-follow 檢——允許使用者把整個 cache symlink 到別處的安全位置)。**有界殘留**:本函式驗完到
632
+ `atomic_create` 寫入間的 µs 窗,dest 仍可能被換成 symlink(同 Block 3c 父夾 symlink;受非對抗模型約束、不上
633
+ POSIX-only dir_fd)。"""
634
+ root, dest = Path(root), Path(dest)
635
+ # os_path:暫存路徑常 >260(<pk>/<key> 各 ~200 巢狀)→ Windows 走 \\?\ 繞過 MAX_PATH(reparse_kind 已內建)。
636
+ os.makedirs(atomicio.os_path(root), exist_ok=True) # root + 祖先(CLI 已 resolve 驗在 hub/local 之外)
637
+ parts = dest.relative_to(root).parts
638
+ cur = root
639
+ claimed_leaf = False
640
+ for i, part in enumerate(parts):
641
+ cur = cur / part
642
+ try:
643
+ os.mkdir(atomicio.os_path(cur)) # 非 FileExistsError 的 OSError 自然向上拋(呼叫端轉 error)
644
+ if i == len(parts) - 1:
645
+ claimed_leaf = True
646
+ except FileExistsError:
647
+ pass
648
+ # 拒 **任何** reparse point(symlink / **junction** / cloud / 未知)+非目錄(防重導)。**junction 必須也拒**
649
+ # (e2e Pass1 High):`os.path.islink` 在 Windows 對 junction 回 False(junction 非 symlink),舊檢查會放行
650
+ # 指向正式 memory/hub 的 junction → 兩版/PROMPT.md 寫穿進同步區=明文外洩。此處是**工具自有 staging**,ccdir
651
+ # 政策明定其 reparse 一律 fail-closed(與 memory/ 夾**跟隨** junction 相反)→ 用 `reparse_kind != "none"` 全拒。
652
+ if memory.reparse_kind(cur, long_path=True) != "none" or not os.path.isdir(atomicio.os_path(cur)):
653
+ raise UnsafeStagingPath(f"暫存路徑含 symlink/junction/reparse 或非目錄:{cur}")
654
+ return "claimed" if claimed_leaf else "already-staged"
655
+
656
+
657
+ def stage_conflict(conflict: MemoryConflict, *, root: Path | None = None,
658
+ apply: bool = False) -> StageResult:
659
+ """把衝突兩版安全保留到暫存夾(approach A)。
660
+
661
+ **claim-the-dir 幂等**:逐層 no-follow 佔用 `<root>/<pk>/<key>`(`_claim_staging_dir`,拒 symlink 路徑,gate F1);
662
+ leaf 已存在 → 看**完成標記** `.done`:有 → `already-staged`(不覆蓋,保護使用者刪減/合併的內容,§7.3);無 →
663
+ `incomplete`(上次中途失敗的殘缺暫存,gate F3)→ 報失敗 + 指示刪除重跑,**不**當成已完成。每個版本走
664
+ `atomic_create_bytes`(O_EXCL);全部寫完才寫 `.done`。**只讀正式 memory、只寫暫存**——永不碰 `memory/`、永不刪
665
+ 任何檔(A3)。`apply=False` → would-stage 預覽。無可保留內容 → `empty`。`conflict.notes`(plan 後退化)一律
666
+ 併入結果並使 CLI 非零(gate F2)。"""
667
+ root = Path(root) if root is not None else merge_root()
668
+ dest = staging_dir(root, conflict)
669
+ base_notes = list(conflict.notes)
670
+ staged_versions = conflict.staged_versions()
671
+ if not staged_versions:
672
+ return StageResult(conflict, dest, "empty", [],
673
+ base_notes + ["無可保留的版本內容(全部讀不到/損壞)"])
674
+ if not apply:
675
+ # 預覽也比對 fingerprint(gate5 F1):陳舊(衝突已換 kind/內容)顯示 stale,與 apply 一致、不誤報 already。
676
+ state = _completed_match(dest, conflict)
677
+ status = {"same": "already-staged", "stale": "stale"}.get(state, "would-stage")
678
+ extra = [f"暫存內容與目前衝突不符(衝突已變);請刪除 {dest} 後重跑"] if state == "stale" else []
679
+ return StageResult(conflict, dest, status, planned_staged_names(conflict), base_notes + extra)
680
+ try:
681
+ claim = _claim_staging_dir(root, dest)
682
+ except UnsafeStagingPath as e:
683
+ return StageResult(conflict, dest, "error", [], base_notes + [f"暫存路徑不安全(拒絕寫入):{e}"])
684
+ except OSError as e:
685
+ return StageResult(conflict, dest, "error", [], base_notes + [f"建立暫存夾失敗:{e}"])
686
+ if claim == "already-staged":
687
+ # `.done` 在 → 比對 fingerprint:同一衝突=already-staged 幂等;**已換 kind/內容=stale**(不可用舊證據
688
+ # 遮蓋新衝突,gate5 F1)。`.done` 不在 → incomplete(上次中途失敗)。皆不覆蓋使用者已刪減/合併的內容。
689
+ state = _completed_match(dest, conflict)
690
+ if state == "same":
691
+ return StageResult(conflict, dest, "already-staged", [], base_notes)
692
+ if state == "stale":
693
+ return StageResult(conflict, dest, "stale", [], base_notes
694
+ + [f"暫存內容與目前衝突不符(衝突已換 kind/內容);請刪除 {dest} 後重跑"])
695
+ return StageResult(conflict, dest, "incomplete", [], base_notes
696
+ + [f"暫存夾殘缺(上次中途失敗,缺 {DONE_FILE});請刪除 {dest} 後重跑"])
697
+ written: list[str] = []
698
+ notes: list[str] = list(base_notes)
699
+ staged_map: dict[int, str] = {}
700
+ ok = True
701
+ for i, v in enumerate(conflict.versions):
702
+ if v.is_tombstone or v.data is None:
703
+ continue
704
+ sname = _safe_component(f"{v.label}__{v.filename}")
705
+ try:
706
+ atomicio.atomic_create_bytes(dest / sname, v.data, long_path=True) # 深 staging 路徑 → \\?\
707
+ written.append(sname)
708
+ staged_map[i] = sname
709
+ except FileExistsError:
710
+ # dest 是本次新佔用的空夾 + _safe_component injective → 同名衝突屬真實異常(並發/不該發生)。
711
+ # 標成失敗(含「失敗」→ CLI 非零退出),不靜默略過丟版本(codex R1 High:collision 須當 error)。
712
+ notes.append(f"{sname}: 暫存檔意外已存在,寫入失敗(未覆蓋;請回報)")
713
+ ok = False
714
+ except (OSError, atomicio.AtomicWriteError) as e:
715
+ notes.append(f"{sname}: 寫入失敗 {e}")
716
+ ok = False
717
+ degraded = bool(conflict.notes) # plan 後某側讀不到 → 保留不完整(gate2 F1)
718
+ try:
719
+ atomicio.atomic_write_text(
720
+ dest / META_FILE, json.dumps(_conflict_meta(conflict, staged_map), ensure_ascii=False, indent=2),
721
+ long_path=True) # 深 staging 路徑 → \\?\
722
+ atomicio.atomic_write_text(dest / PROMPT_FILE, build_prompt(conflict), long_path=True)
723
+ # 只有「全部寫成功**且非退化**」才落 `.done`(gate2 F1):退化暫存缺 .done → 下次偵測為 incomplete、
724
+ # 不會被當 already-staged 而永久卡住缺檔;提示詞/中繼已帶退化警告,使用者知道要刪除重跑補齊。
725
+ if ok and not degraded:
726
+ atomicio.atomic_write_text(dest / DONE_FILE, "", long_path=True)
727
+ except (OSError, atomicio.AtomicWriteError) as e:
728
+ notes.append(f"中繼/提示詞寫入失敗 {e}")
729
+ ok = False
730
+ status = "error" if not ok else ("degraded" if degraded else "staged")
731
+ return StageResult(conflict, dest, status, written, notes)
732
+
733
+
734
+ # ── 合併提示詞(明文外洩警告)─────────────────────────────────────────────────
735
+
736
+ def build_prompt(conflict: MemoryConflict) -> str:
737
+ """產生給 Claude 的合併提示詞(含明文外洩警告抬頭)。純文字、只組裝已讀入的版本內容;不寫任何檔。
738
+ 呼叫端決定輸出到 stdout 或本機暫存(皆不同步)——**絕不**由本工具自動送進 Claude。"""
739
+ lines: list[str] = ["<!-- claude-session-sync memory-merge 提示詞(本機產生;勿回寫到任何會被同步的位置)-->",
740
+ LEAK_WARNING, "",
741
+ "# 任務:合併衝突的 Claude memory", "",
742
+ f"- 專案:{_disp(conflict.project_key)}",
743
+ f"- 衝突類型:{_KIND_ZH.get(conflict.kind, conflict.kind)}(`{conflict.kind}`)",
744
+ f"- 鍵:{_key_disp(conflict.key)}",
745
+ f"- 偵測原因:{_disp(conflict.reason)}", ""]
746
+ if conflict.notes: # 退化警告(某側讀不到、保留不完整)持久化進提示詞(gate2 F1),合併者須知本次不完整。
747
+ lines.append("- ⚠ **本次保留不完整**(請刪除暫存夾後重跑 sync 補齊;勿據此最終定案):")
748
+ for n in conflict.notes:
749
+ lines.append(f" - {_disp(n)}")
750
+ lines.append("")
751
+ tombs = [v for v in conflict.versions if v.is_tombstone]
752
+ if tombs:
753
+ # **列出所有** tombstone(codex R1 Medium:多筆換檔名刪除全數呈現,不可只顯示第一筆 → 否則藏掉某些刪除)。
754
+ lines.append("- ⚠ 有一方/多方曾**刪除**此記憶。刪除常是移除過期/錯誤/敏感資訊——請評估該尊重刪除、"
755
+ "還是保留更新版;不確定就交人。已刪版本:")
756
+ for t in tombs:
757
+ lines.append(f" - `{_disp(t.filename)}`(base hash `{_short(t.base_hash)}`"
758
+ + (f"、identity `{_disp(t.identity)}`" if t.identity else "")
759
+ + (f"、來源 `{_disp(t.machine)}`" if t.machine else "")
760
+ + (f"、時間 `{_disp(t.time)}`" if t.time else "") + ")")
761
+ lines.append("")
762
+ if conflict.kind == FUZZY_KIND:
763
+ # fuzzy:兩檔是**近似比對疑似**同一事實(不同檔名、非確定),連提示詞都須守 advisory——先要求確認、允許判「其實
764
+ # 不同」→ 不合併(保留兩則各自的檔),不可假設一定同一則。
765
+ lines += [
766
+ "下面是兩個**不同檔名**的 memory,被近似比對標為**疑似**同一事實(**尚未確認**)。請**先判斷它們是否真是"
767
+ "同一件事**:",
768
+ "- 若**其實是兩件不同的事**(只是用詞或主題相近)→ **不要合併**,說明理由、維持兩則各自獨立即可;",
769
+ "- 若**確是同一件事** → 把它們**合併成單一 memory `.md`**:保留所有**不同的事實**、不遺漏任一版本獨有內容;"
770
+ "不杜撰未出現的資訊;保留 frontmatter(`name`/`description`/`metadata`),衝突欄位取較完整者、必要時在正文"
771
+ "註明出處;兩版**矛盾**(非互補)就標出來、兩種說法都留、交人決定。",
772
+ "",
773
+ "請先明確說出「同一件事 / 不同的事」的判斷;若判定合併,再**只輸出**最終 `.md` 內容(含 frontmatter)。"
774
+ "完成後本工具不會自動寫回 `memory/`——請自行覆蓋正式檔再重跑 `sync` 傳播(合併時記得刪掉多餘的那個舊檔)。",
775
+ "",
776
+ ]
777
+ else:
778
+ lines += [
779
+ "下面是同一則記憶的多個版本(已從本機與 hub 各自保留到暫存區)。請把它們**合併成單一 memory `.md`**:",
780
+ "- 保留所有**不同的事實**,不要遺漏任一版本獨有的內容;",
781
+ "- 不要杜撰或推測未出現的資訊;",
782
+ "- 保留 frontmatter(`name`/`description`/`metadata`);衝突欄位取較完整者,必要時在正文註明出處;",
783
+ "- 若兩版**矛盾**(非互補),不要自行裁定——標出來、兩種說法都保留、交人決定。",
784
+ "",
785
+ "合併後請**只輸出**最終 `.md` 內容(含 frontmatter)。完成後本工具不會自動寫回 `memory/`;"
786
+ "請自行覆蓋正式檔再重跑 `sync` 傳播。",
787
+ "",
788
+ ]
789
+ n = 0
790
+ for v in conflict.versions:
791
+ if v.is_tombstone:
792
+ continue
793
+ n += 1
794
+ head = f"## 版本 {n}:{_disp(v.label)}(`{_disp(v.filename)}`"
795
+ head += f",hash `{_short(v.content_hash)}`)" if v.content_hash else ",內容損壞/無法解碼)"
796
+ lines.append(head)
797
+ if v.text is None:
798
+ lines.append("(無法解碼或已損壞——請改看暫存檔原始 bytes。)")
799
+ else:
800
+ fence = _fence_for(v.text) # 動態長度,防內容中的 ``` 提前關閉 fence
801
+ lines.append(fence + "md")
802
+ lines.append(v.text)
803
+ lines.append(fence)
804
+ lines.append("")
805
+ return "\n".join(lines).rstrip("\n") + "\n"
806
+
807
+
808
+ def _short(h: str | None) -> str:
809
+ return (h[:12] if h else "?")
810
+
811
+
812
+ # ── CLI 報告 ──────────────────────────────────────────────────────────────────
813
+
814
+ def format_conflicts(conflicts: list[MemoryConflict], *, root: Path | None = None,
815
+ apply: bool = False) -> str:
816
+ """dry-run / 預覽報告:列每個衝突 + 各版本 + 暫存目標路徑。"""
817
+ root = Path(root) if root is not None else merge_root()
818
+ lines: list[str] = []
819
+ for c in conflicts:
820
+ dest = staging_dir(root, c)
821
+ lines.append(f"\n● {_disp(c.project_key)} / {_key_disp(c.key)} [{_KIND_ZH.get(c.kind, c.kind)}]")
822
+ lines.append(f" 原因:{_disp(c.reason)}")
823
+ for v in c.versions:
824
+ if v.is_tombstone:
825
+ lines.append(f" - tombstone(已刪除;base `{_short(v.base_hash)}`"
826
+ + (f",identity `{_disp(v.identity)}`" if v.identity else "") + ")")
827
+ else:
828
+ tag = "" if v.content_hash else "(損壞/無法解碼)"
829
+ lines.append(f" - {_disp(v.label)}:`{_disp(v.filename)}` hash `{_short(v.content_hash)}`{tag}")
830
+ for n in c.notes:
831
+ lines.append(f" ⚠ {_disp(n)}")
832
+ verb = "暫存於" if apply else "將暫存於"
833
+ # dest 的根來自 XDG_CACHE_HOME(POSIX 可含非 UTF-8 bytes/控制字元)→ 過 _disp,免預覽輸出崩 strict stdout
834
+ # 或破壞單行(g4 Low,同 `_print_stage(res.dest)` 一類)。
835
+ lines.append(f" {verb}:{_disp(str(dest))}")
836
+ return "\n".join(lines)