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,370 @@
1
+ """bootstrap:首次同步建基線(決定 #9 + codex r3 信任邊界)。掃兩邊現況 → 寫 coverage + state baseline。
2
+
3
+ **不複製、不刪 session/memory 資料**:只寫 per-project `_coverage.json`(initialized)+ state(known/
4
+ local/bindings/hub_fingerprint)+ 被 `--ignore` 的單邊檔的 suppress tombstone。
5
+
6
+ 信任邊界(codex r3):現存**單邊**檔在 bootstrap 後會被視為「可匯入」(下次 sync 複製到對側)。故落地前
7
+ 必須印出**完整 baseline diff** 並由使用者確認;不想傳播的「刪除殘留」以 `--ignore` 排除——
8
+ 排除 = 寫一條 suppress tombstone(檔留原地、永不複製到對側),不刪檔。
9
+
10
+ **P1d Block 3a:memory 基線(A17.1 對稱 session)。** 同時掃 `<proj>/memory/` 寫 `known_memory`/`local_memory`
11
+ 基線——否則 `memory.classify_memory` 對單邊 memory 一律 `blocked-no-baseline`(刻意 fail-closed)。`--ignore`
12
+ 也涵蓋 memory 檔名(與 sid 命名空間天然不交集:sid=UUID、memory=`*.md`)→ 寫 memory suppress tombstone
13
+ (base=正規化 `content_hash`、identity=frontmatter `name`,對稱 Block 2b 契約;走 per-project memory 鎖,與
14
+ 未來 memory apply 互斥)。`memory/` 根是 symlink(`UnsafeMemoryDir`)→ 不建該專案 memory 基線(fail-closed,
15
+ 下次 sync 仍 blocked-no-baseline,不誤把指向空/錯夾當「memory 全空」而傳播刪除)。
16
+
17
+ P1b 範圍:只 bless **已配對**(兩側夾都在且 git 指紋相符)或 **--map 明示**的專案;判不出一律 skip 待
18
+ `--map`(決定 #7,不猜跨 OS 編碼夾名)。空 hub 的首推請用 `--map <local夾名>=<hub夾名>`。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from collections import Counter
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from . import anomaly, atomicio, memory, scan, state as state_mod, tombstone
27
+ from .anomaly import Anomaly
28
+ from .state import State
29
+
30
+
31
+ class BootstrapChanged(RuntimeError):
32
+ """確認後、落地前,磁碟現況已與所確認的 baseline diff 不符 → 拒絕落地,請重跑(codex r9-1)。"""
33
+
34
+
35
+ def _safe_hub_name(name: str) -> bool:
36
+ """--map 的 hub 目標必須是 hub_root 底下的**單一安全夾名**:非空、不含分隔、非 . / ..、非絕對路徑。
37
+ 擋 `../outside`、絕對路徑等逃出 hub 信任根(codex r9-3)。"""
38
+ return bool(name) and name == Path(name).name and name not in (".", "..")
39
+
40
+
41
+ @dataclass
42
+ class ProjectBaseline:
43
+ local_dir: str | None
44
+ hub_dir: str | None
45
+ project_key: str | None # hub 夾名(known_sessions / bindings 的 key)
46
+ cwd: str | None
47
+ both: list[str] = field(default_factory=list)
48
+ local_only: list[str] = field(default_factory=list)
49
+ hub_only: list[str] = field(default_factory=list)
50
+ ignored: list[str] = field(default_factory=list)
51
+ # memory(檔名集,diff 同 session;Block 3a)。mem_unsafe=memory/ 根是 symlink → 不建記憶基線。
52
+ mem_both: list[str] = field(default_factory=list)
53
+ mem_local_only: list[str] = field(default_factory=list)
54
+ mem_hub_only: list[str] = field(default_factory=list)
55
+ mem_ignored: list[str] = field(default_factory=list)
56
+ mem_unsafe: bool = False
57
+ status: str = "mapped" # mapped / skipped-<reason>
58
+
59
+ @property
60
+ def importable(self) -> list[str]:
61
+ """bootstrap 後會被複製到對側的單邊 session 檔(已扣除 ignored)。"""
62
+ ig = set(self.ignored)
63
+ return sorted((set(self.local_only) | set(self.hub_only)) - ig)
64
+
65
+ @property
66
+ def mem_importable(self) -> list[str]:
67
+ """bootstrap 後會被複製到對側的單邊 memory 檔(已扣除 mem_ignored)。"""
68
+ ig = set(self.mem_ignored)
69
+ return sorted((set(self.mem_local_only) | set(self.mem_hub_only)) - ig)
70
+
71
+
72
+ @dataclass
73
+ class BootstrapPlan:
74
+ projects: list[ProjectBaseline]
75
+ anomalies: list[Anomaly]
76
+
77
+ @property
78
+ def halt(self) -> bool:
79
+ return any(a.severity == "halt" for a in self.anomalies)
80
+
81
+ @property
82
+ def mapped(self) -> list[ProjectBaseline]:
83
+ return [p for p in self.projects if p.status == "mapped"]
84
+
85
+
86
+ def _stems(d: Path | None) -> set[str]:
87
+ return set(scan._session_files(d).keys()) if d else set()
88
+
89
+
90
+ def _mem_names(proj_dir: Path | None) -> tuple[set[str], bool]:
91
+ """專案夾下 `memory/` 的 memory 檔名集 + unsafe 旗標。回 (names, unsafe)。
92
+
93
+ unsafe=True 表 `memory/` 根是 symlink(`UnsafeMemoryDir`)→ 上層不建該專案記憶基線(fail-closed)。
94
+ 其它 OSError(不可讀夾)刻意**不吞**、向上拋(fail-stop,與 `list_memory_files` 一致——把不可讀誤當
95
+ 「memory 全空」會看似大量刪除)。proj_dir=None / memory 夾不存在 → (空集, False)。"""
96
+ if proj_dir is None:
97
+ return set(), False
98
+ try:
99
+ return set(memory.list_memory_files(memory.memory_dir(proj_dir)).keys()), False
100
+ except memory.UnsafeMemoryDir:
101
+ return set(), True
102
+
103
+
104
+ def scan_baseline(
105
+ local_root, hub_root, state: State | None, *,
106
+ identity_fn=None, mappings: dict[str, str] | None = None, ignore: set[str] | None = None,
107
+ ) -> BootstrapPlan:
108
+ """唯讀算出每個專案的 baseline diff(不寫任何檔)。供顯示 + 確認。
109
+
110
+ mappings:{local 夾名 → hub 夾名} 明示對應(覆寫 git 指紋解析,供空 hub 首推 / 指紋判不出時)。
111
+ """
112
+ local_root, hub_root = Path(local_root), Path(hub_root)
113
+ mappings = mappings or {}
114
+ ignore = ignore or set()
115
+ resolve = identity_fn or scan._git_identity
116
+
117
+ anomalies = anomaly.check(state, hub_root)
118
+ if any(a.severity == "halt" for a in anomalies):
119
+ return BootstrapPlan(projects=[], anomalies=anomalies)
120
+
121
+ # 逃逸專案夾過濾(e2e gate G-Low + xgrp #4):symlink/逃出 root 的 reparse 夾不讀/不寫(連 sidecar/cwd 都不碰)。
122
+ # root 內 junction 允許(resolve 仍在 root 內)。與 build_plan/transfer/doctor 同一把 `_safe_project_dir`。
123
+ hub_dirs, _hub_unsafe = scan._list_project_dirs(hub_root)
124
+ local_dirs, local_unsafe = scan._list_project_dirs(local_root)
125
+
126
+ projects: list[ProjectBaseline] = []
127
+ for ld in local_unsafe: # 逃逸 local 夾 → skipped-unsafe(不從 root 外夾建基線/bless)
128
+ projects.append(ProjectBaseline(
129
+ local_dir=str(ld), hub_dir=None, project_key=None, cwd=None, status="skipped-unsafe"))
130
+ for ld in local_dirs:
131
+ # --map 明示優先;否則走 git 指紋解析(hub_dirs 已濾逃逸,git 解析不會讀到不安全 hub 夾 sidecar)。
132
+ if ld.name in mappings:
133
+ if _safe_hub_name(mappings[ld.name]):
134
+ hub_dir = hub_root / mappings[ld.name]
135
+ status = "mapped"
136
+ else:
137
+ hub_dir, status = None, "skipped-bad-map" # 逃出 hub 信任根 → 拒絕
138
+ else:
139
+ st, hub_dir = resolve(ld, hub_dirs)
140
+ status = "mapped" if st == "match" else f"skipped-{st}"
141
+
142
+ # hub 專案夾逃逸檢查(--map 目標或 git 解析到的 hub 夾若為既存 symlink/junction 逃出 hub_root)→ 不建基線、
143
+ # 不寫 tombstone/coverage 到 root 外(e2e xgrp #4)。待建的空 hub 夾(不存在)resolve 字面仍在 root 內 → 放行。
144
+ if status == "mapped" and hub_dir is not None and not scan._safe_project_dir(hub_root, hub_dir):
145
+ hub_dir, status = None, "skipped-unsafe"
146
+
147
+ cwds = scan._project_cwds(ld)
148
+ cwd = next(iter(cwds)) if len(cwds) == 1 else None
149
+ if status == "mapped" and len(cwds) > 1:
150
+ status = "skipped-multi-cwd" # 夾名有損混入多 cwd → 不可建單一綁定
151
+
152
+ # 不可列舉夾 fail-stop(e2e gate11 finding2):local/hub 專案夾存在但不可讀(POSIX read-denied)→ `_stems`
153
+ # 的 glob **fail-open** 回空 → baseline 漏掉現存 session → 日後真正刪除認不出、hub 檔被復活。故不建基線、標
154
+ # skipped-unreadable(fail-closed;memory 基線 `_mem_names`/list_memory_files 本就 fail-stop 一致)。可讀但
155
+ # 真的空 → 照常建(空基線語意不變)。放在 `_stems`/`_project_cwds` 讀之前總擋。
156
+ if not scan._dir_scannable(ld) or (hub_dir is not None and not scan._dir_scannable(hub_dir)):
157
+ projects.append(ProjectBaseline(
158
+ local_dir=str(ld), hub_dir=str(hub_dir) if hub_dir else None,
159
+ project_key=None, cwd=cwd, status="skipped-unreadable"))
160
+ continue
161
+
162
+ local_s = _stems(ld)
163
+ hub_s = _stems(hub_dir) if (hub_dir and hub_dir.exists()) else set()
164
+ both = sorted(local_s & hub_s)
165
+ local_only = sorted(local_s - hub_s)
166
+ hub_only = sorted(hub_s - local_s)
167
+ single = set(local_only) | set(hub_only)
168
+ # memory 基線(檔名 diff,對稱 session;mem_unsafe → 任一側 memory/ 根是 symlink,不建記憶基線)。
169
+ local_m, lmu = _mem_names(ld)
170
+ hub_m, hmu = _mem_names(hub_dir if (status == "mapped" and hub_dir) else None)
171
+ mem_both = sorted(local_m & hub_m)
172
+ mem_local_only = sorted(local_m - hub_m)
173
+ mem_hub_only = sorted(hub_m - local_m)
174
+ mem_single = set(mem_local_only) | set(mem_hub_only)
175
+ projects.append(ProjectBaseline(
176
+ local_dir=str(ld),
177
+ hub_dir=str(hub_dir) if hub_dir else None,
178
+ project_key=hub_dir.name if (status == "mapped" and hub_dir) else None,
179
+ cwd=cwd,
180
+ both=both, local_only=local_only, hub_only=hub_only,
181
+ ignored=sorted(single & ignore),
182
+ mem_both=mem_both, mem_local_only=mem_local_only, mem_hub_only=mem_hub_only,
183
+ mem_ignored=sorted(mem_single & ignore), mem_unsafe=(lmu or hmu),
184
+ status=status,
185
+ ))
186
+
187
+ # 多個 local 撞同一 hub project_key、或同一 cwd → 落地會互覆/誤綁 → 全數 skip(不挑)(codex r9-3)。
188
+ mapped = [p for p in projects if p.status == "mapped"]
189
+ key_dups = {k for k, n in Counter(p.project_key for p in mapped).items() if n > 1}
190
+ cwd_dups = {c for c, n in Counter(p.cwd for p in mapped if p.cwd is not None).items() if n > 1}
191
+ for p in mapped:
192
+ if p.project_key in key_dups:
193
+ p.status = "skipped-dup-key"
194
+ elif p.cwd is not None and p.cwd in cwd_dups:
195
+ p.status = "skipped-dup-cwd"
196
+ return BootstrapPlan(projects=projects, anomalies=anomalies)
197
+
198
+
199
+ def format_baseline(plan: BootstrapPlan) -> str:
200
+ lines: list[str] = []
201
+ for a in plan.anomalies:
202
+ lines.append(f"[{a.severity.upper()}] {a.code}: {a.message}")
203
+ if plan.halt:
204
+ lines.append("→ halt 級異常,bootstrap 中止。")
205
+ return "\n".join(lines)
206
+ lines.append("bootstrap baseline 預覽(**不複製、不刪**;確認後現存單邊檔將於下次 sync 視為可匯入):")
207
+ for p in plan.projects:
208
+ head = p.local_dir or p.hub_dir
209
+ if p.status != "mapped":
210
+ lines.append(f"\n專案 {head} [{p.status}] → 跳過(需 --map)")
211
+ continue
212
+ lines.append(f"\n專案 {head} → hub={p.project_key} cwd={p.cwd}")
213
+ lines.append(f" 兩側皆有:{len(p.both)};local-only:{len(p.local_only)};hub-only:{len(p.hub_only)}")
214
+ if p.importable:
215
+ lines.append(f" ⚠ 將被視為可匯入(複製到對側):{', '.join(s[:8] for s in p.importable)}")
216
+ if p.ignored:
217
+ lines.append(f" · 已忽略(寫 suppress tombstone、不傳播):{', '.join(s[:8] for s in p.ignored)}")
218
+ if p.mem_unsafe:
219
+ lines.append(" · ⚠ memory/ 根是 symlink → 跳過記憶基線(請改為實體目錄後重跑)")
220
+ else:
221
+ lines.append(f" 記憶:兩側 {len(p.mem_both)};local-only {len(p.mem_local_only)};"
222
+ f"hub-only {len(p.mem_hub_only)}")
223
+ if p.mem_importable:
224
+ lines.append(f" ⚠ 記憶將被視為可匯入:{', '.join(p.mem_importable)}")
225
+ if p.mem_ignored:
226
+ lines.append(f" · 記憶已忽略(寫 suppress tombstone):{', '.join(p.mem_ignored)}")
227
+ if not plan.mapped:
228
+ lines.append("\n(無已配對專案可建基線;請用 --map <local夾名>=<hub夾名> 明示對應)")
229
+ return "\n".join(lines)
230
+
231
+
232
+ def _revalidate(plan: BootstrapPlan) -> None:
233
+ """落地前重掃每個 mapped 專案,與**所確認**的 diff 比對;不符即中止(codex r9-1:擋確認後冒出的檔
234
+ 被悄悄 bless)。bootstrap 是明確的一次性操作,重掃-比對足以擋住確認後的漂移。"""
235
+ for p in plan.mapped:
236
+ cur_local = _stems(Path(p.local_dir)) if p.local_dir else set()
237
+ cur_hub = _stems(Path(p.hub_dir)) if (p.hub_dir and Path(p.hub_dir).exists()) else set()
238
+ if cur_local != set(p.both) | set(p.local_only) or cur_hub != set(p.both) | set(p.hub_only):
239
+ raise BootstrapChanged(
240
+ f"專案 {p.project_key} 自確認後內容已變(有檔新增/移除)——請重跑 bootstrap 再確認。"
241
+ )
242
+ # memory drift(mem_unsafe 的專案不建記憶基線 → 不比;重掃變 unsafe 由下方集合不符觸發 abort)。
243
+ if not p.mem_unsafe:
244
+ cur_lm, lmu = _mem_names(Path(p.local_dir)) if p.local_dir else (set(), False)
245
+ cur_hm, hmu = _mem_names(Path(p.hub_dir)) if p.hub_dir else (set(), False)
246
+ if (lmu or hmu or cur_lm != set(p.mem_both) | set(p.mem_local_only)
247
+ or cur_hm != set(p.mem_both) | set(p.mem_hub_only)):
248
+ raise BootstrapChanged(
249
+ f"專案 {p.project_key} 記憶自確認後已變(檔案增減或 memory/ 變 symlink)——請重跑 bootstrap。"
250
+ )
251
+
252
+
253
+ def apply_baseline(
254
+ plan: BootstrapPlan, hub_root, state_path, *,
255
+ machine: str | None = None, lock_timeout_s: float = 5.0,
256
+ ) -> dict:
257
+ """確認後落地。次序刻意為 **tombstone → state baseline(加鎖) → coverage(最後)**:coverage 是
258
+ 「此專案可開始匯入」的信任邊界 go 訊號,放最後 → 若 state 提交失敗,專案仍 uninitialized(sync 續 block),
259
+ 不會出現「已 initialized 但無 baseline」的危險半成品(codex r9-2)。不複製、不刪 session 資料。"""
260
+ if plan.halt:
261
+ raise RuntimeError("plan 含 halt 異常,拒絕 apply")
262
+ hub_root = Path(hub_root)
263
+ # 落地前重檢掛載/存在性(scan 與 apply 之間 hub 可能消失/掛錯)——否則會在錯的 FS 上建空夾並 bless(codex r11-5)。
264
+ rehalt = [a for a in anomaly.check(None, hub_root) if a.severity == "halt"]
265
+ if rehalt:
266
+ raise RuntimeError("落地前重檢異常:" + "; ".join(f"{a.code}: {a.message}" for a in rehalt))
267
+ # 落地前**先**重驗逃逸(先於 `_revalidate` 的讀取,e2e gate3 #4):scan 與 apply 間夾可能被換成 symlink/junction;
268
+ # hub 是寫入目標、local 是讀取來源,逃逸都不可跟隨(否則 `_revalidate` 會先讀界外樹再拒=讀-escape)。任一不符 → 拒絕。
269
+ for p in plan.mapped:
270
+ hd = Path(p.hub_dir)
271
+ if not scan._safe_project_dir(hub_root, hd):
272
+ raise BootstrapChanged(
273
+ f"專案 {p.project_key} 的 hub 夾自確認後成 symlink/逃逸 hub_root → 拒絕落地(不寫中繼到信任根外),請重跑。")
274
+ if not scan._safe_project_dir(hd, hd / tombstone.TOMB_DIR): # .tombstones 逃逸 → 不寫 coverage/tombstone 到界外(e2e gate3 #3)
275
+ raise BootstrapChanged(
276
+ f"專案 {p.project_key} 的 .tombstones 為 symlink/逃逸 → 拒絕落地(不寫中繼到界外),請重跑。")
277
+ if p.local_dir is not None and not scan._safe_project_dir(Path(p.local_dir).parent, Path(p.local_dir)):
278
+ raise BootstrapChanged(
279
+ f"專案 {p.project_key} 的 local 夾自確認後成 symlink/逃逸 → 拒絕落地,請重跑。")
280
+ _revalidate(plan)
281
+
282
+ tombstoned: list[str] = []
283
+ mem_tombstoned: list[str] = []
284
+ # 1) 建 hub 夾(空 hub 首推)+ ignored 單邊檔 → suppress tombstone(檔留原地、永不複製)。
285
+ for p in plan.mapped:
286
+ hub_dir = Path(p.hub_dir)
287
+ hub_dir.mkdir(parents=True, exist_ok=True)
288
+ local_dir = Path(p.local_dir) if p.local_dir else None
289
+ for sid in p.ignored:
290
+ src = (hub_dir / f"{sid}.jsonl") if sid in p.hub_only else (
291
+ (local_dir / f"{sid}.jsonl") if local_dir else None)
292
+ base = tombstone.raw_file_digest(src) if (src and src.exists()) else None
293
+ # 與 apply 共用同一把 per-session 鎖(hub 側路徑),讓 tombstone 寫與 apply 的 gate 互斥
294
+ # ——否則 tombstone 可能在 apply gate 之後、copy 之前才出現而復活已抑制的 session(codex r10-4)。
295
+ lk = atomicio.FileLock(hub_dir / f"{sid}.jsonl").acquire_blocking(timeout_s=lock_timeout_s)
296
+ try:
297
+ tombstone.write_session_tombstone(hub_dir, sid, base_hash=base, machine=machine)
298
+ finally:
299
+ lk.release()
300
+ tombstoned.append(f"{p.project_key}:{sid}")
301
+ # ignored memory → suppress tombstone(base=正規化 content_hash、identity=frontmatter name,對稱
302
+ # Block 2b 契約)。走 **per-project memory 鎖**(hub 側 `.tombstones/memory`),與未來 memory apply 的
303
+ # gate 互斥(同 session:tombstone 寫須在 apply 取鎖前後互斥,免復活已抑制 memory)。mem_unsafe 不寫。
304
+ mem_ig = [n for n in p.mem_ignored if not p.mem_unsafe]
305
+ if mem_ig:
306
+ hub_mdir = memory.memory_dir(hub_dir)
307
+ local_mdir = memory.memory_dir(local_dir) if local_dir else None
308
+ mlk = atomicio.FileLock(
309
+ tombstone.tombstones_dir(hub_dir) / "memory").acquire_blocking(timeout_s=lock_timeout_s)
310
+ try:
311
+ for name in mem_ig:
312
+ if not tombstone.is_tombstone_safe_name(name):
313
+ continue # 含路徑分隔的不可逆檔名 → 不寫 tombstone(sync classify 另以 blocked-unsupported-name 擋)
314
+ src = (hub_mdir / name) if name in p.mem_hub_only else (
315
+ (local_mdir / name) if local_mdir else None)
316
+ doc = memory.load_memory(src) if (src and src.exists()) else None
317
+ base = memory.content_hash(doc) if doc else None # damaged → None(tombstone 仍擋傳播,走 conflict)
318
+ identity = doc.name if doc else None # frontmatter name slug(無 → None)
319
+ tombstone.write_memory_tombstone(hub_dir, name, base_hash=base,
320
+ machine=machine, identity=identity)
321
+ mem_tombstoned.append(f"{p.project_key}:mem:{name}")
322
+ finally:
323
+ mlk.release()
324
+
325
+ # 2) state baseline:一次加鎖 RMW,保留未 bootstrap 的專案條目。known/bindings 取自**所確認**的
326
+ # plan(非重新 glob),避免確認後冒出的檔被納入 baseline。
327
+ ignore_by_pk = {p.project_key: set(p.ignored) for p in plan.mapped}
328
+ fp = anomaly.hub_fingerprint(hub_root)
329
+
330
+ def _mutate(s: State) -> None:
331
+ s.hub_fingerprint = fp
332
+ for p in plan.mapped:
333
+ ig = ignore_by_pk.get(p.project_key, set())
334
+ # known = 確認當下 hub 現況(both ∪ hub_only)- ignored(local-only 待 sync 複製後才記為已知)
335
+ s.known_sessions[p.project_key] = (set(p.both) | set(p.hub_only)) - ig
336
+ # local baseline = 確認當下 local 現況(both ∪ local_only)- ignored(對稱刪除追蹤起點,P1c)。
337
+ # 之後本機刪除某 local session 時,下次 sync 才能分辨「本機刪除」與「對側新檔」。
338
+ s.local_sessions[p.project_key] = (set(p.both) | set(p.local_only)) - ig
339
+ # memory 基線(對稱 session;A17.1)。**mem_unsafe 不建**——否則把 symlink 指向的空/錯夾當「memory
340
+ # 全空」基線,下次 sync 會把對側 memory 當「本機已刪」而寫抑制 tombstone。不建 → 該專案 memory 維持
341
+ # blocked-no-baseline(fail-closed),待改實體目錄重 bootstrap。**空集 ≠ 缺欄位**(後者才是 migration)。
342
+ if not p.mem_unsafe:
343
+ mig = set(p.mem_ignored)
344
+ s.known_memory[p.project_key] = (set(p.mem_both) | set(p.mem_hub_only)) - mig
345
+ s.local_memory[p.project_key] = (set(p.mem_both) | set(p.mem_local_only)) - mig
346
+ else:
347
+ # re-bootstrap 一個曾有基線、現 memory/ 變 symlink 的專案:必須**清掉** stale 基線(pop),否則
348
+ # 舊基線殘留會讓下次 sync 仍以為有可信基線 → 可能把 hub memory 當「本機已刪」寫抑制 tombstone
349
+ # 蓋掉真實 memory(codex 3a-R1 #1 high)。pop 後退回 no-baseline(fail-closed),待改實體目錄重建。
350
+ s.known_memory.pop(p.project_key, None)
351
+ s.local_memory.pop(p.project_key, None)
352
+ if p.cwd is not None:
353
+ s.bindings[p.cwd] = p.project_key
354
+ if p.local_dir is not None:
355
+ # 夾名綁定:供「session 全刪 → 空夾無 cwd」時仍能配對偵測刪除(codex r25)。
356
+ s.local_dir_bindings[Path(p.local_dir).name] = p.project_key
357
+
358
+ state_mod.update_under_lock(_mutate, state_path, lock_timeout_s=lock_timeout_s)
359
+
360
+ # 3) coverage 最後寫(信任邊界 go 訊號):新 epoch(re-bootstrap 視為新紀元,連帶令舊決策快照失效)。
361
+ blessed: list[str] = []
362
+ for p in plan.mapped:
363
+ hub_dir = Path(p.hub_dir)
364
+ prev = tombstone.read_coverage(hub_dir)
365
+ epoch = (prev.epoch + 1) if prev else 1
366
+ tombstone.write_coverage(hub_dir, epoch=epoch, machine=machine)
367
+ blessed.append(p.project_key or "")
368
+
369
+ return {"blessed_projects": blessed, "tombstoned": tombstoned,
370
+ "mem_tombstoned": mem_tombstoned, "hub_fingerprint": fp}
@@ -0,0 +1,185 @@
1
+ """行解析、canonical hash、編碼吸收(畢業自 spikes/canonical.py,加三態 file-state)。
2
+
3
+ 設計依據:DESIGN 附錄 A2/A8 + 附錄 B(B1 跨 OS hash 穩定、非 UTF-8 整檔判 damaged 不 crash)。
4
+
5
+ 三態(PLAN v0.3 / codex H1):
6
+ - ZERO_BYTE:0-byte 檔 → damaged。
7
+ - BLANK:可解碼但只有空白 → damaged(Claude JSONL 不該只有空白)。
8
+ - DECODE_ERROR:任何已知編碼都解不開 → damaged(不 raise)。
9
+ - OK:可解碼且有非空白內容(個別行仍可能是壞 JSON,由 Line.ok 標記)。
10
+ 純標準庫。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import unicodedata
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+ from typing import Any
20
+
21
+ BOM = ""
22
+
23
+ # 依 BOM 前綴選 codec(記事本「另存為」會產生這些)。4-byte 的 UTF-32 要排在 UTF-16 前。
24
+ _BOM_CODECS: list[tuple[bytes, str]] = [
25
+ (b"\xff\xfe\x00\x00", "utf-32-le"),
26
+ (b"\x00\x00\xfe\xff", "utf-32-be"),
27
+ (b"\xff\xfe", "utf-16-le"),
28
+ (b"\xfe\xff", "utf-16-be"),
29
+ (b"\xef\xbb\xbf", "utf-8-sig"),
30
+ ]
31
+
32
+
33
+ class FileState(str, Enum):
34
+ OK = "ok"
35
+ ZERO_BYTE = "zero_byte"
36
+ BLANK = "blank_only"
37
+ DECODE_ERROR = "decode_error"
38
+
39
+ @property
40
+ def is_damaged(self) -> bool:
41
+ return self is not FileState.OK
42
+
43
+
44
+ def _nfc(obj: Any) -> Any:
45
+ """遞迴對字串**值**做 Unicode NFC(解 macOS NFD vs Linux NFC 差異)。
46
+
47
+ **不正規化 dict 鍵**(codex r21):NFC 折疊不同的 key(如 "e\\u0301" 與 "\\u00e9")會在
48
+ dict 推導裡互蓋、**丟掉一個 key/value** → canon_dumps 寫出殘缺行、canon_hash 讓不同行雷同
49
+ (誤去重)。JSON 物件鍵是檔案**內容**(非檔名),OS 不會對它做 NFD/NFC 正規化,故跨 OS 穩定
50
+ 性不需要正規化 key;值才需要(內嵌路徑/檔名在 macOS 可能是 NFD)。"""
51
+ if isinstance(obj, str):
52
+ return unicodedata.normalize("NFC", obj)
53
+ if isinstance(obj, list):
54
+ return [_nfc(x) for x in obj]
55
+ if isinstance(obj, dict):
56
+ return {k: _nfc(v) for k, v in obj.items()}
57
+ return obj
58
+
59
+
60
+ def canon_dumps(obj: Any) -> str:
61
+ """穩定鍵序 + NFC + 緊湊分隔的 canonical JSON 文字(不含換行)。
62
+
63
+ 與 `canon_hash` 共用同一正規化:故 `canon_hash(json.loads(canon_dumps(obj))) == canon_hash(obj)`
64
+ (idempotent)。session_merge 用它輸出 union 行 → 同一 line-identity 在任何機器都序列化成
65
+ 相同 bytes(跨機 union 收斂、再讀回分類一致)。
66
+ """
67
+ return json.dumps(_nfc(obj), sort_keys=True, ensure_ascii=False, separators=(",", ":"))
68
+
69
+
70
+ def canon_hash(obj: Any) -> str:
71
+ """穩定鍵序 + NFC + 緊湊分隔 → sha256。吸收行內空白/換行/鍵序差異,但不壓掉語意差異。"""
72
+ return hashlib.sha256(canon_dumps(obj).encode("utf-8")).hexdigest()
73
+
74
+
75
+ def decode_bytes(raw: bytes) -> tuple[str | None, str | None]:
76
+ """bytes → (text, err);err!=None 表整檔無法解碼。依 BOM 偵測編碼,預設 UTF-8。"""
77
+ for bom, enc in _BOM_CODECS:
78
+ if raw.startswith(bom):
79
+ try:
80
+ return raw.decode(enc), None
81
+ except Exception as e: # noqa: BLE001 - 任何解碼失敗都回 err,不 raise
82
+ return None, f"{enc} decode failed: {e}"
83
+ try:
84
+ return raw.decode("utf-8"), None
85
+ except Exception as e: # noqa: BLE001
86
+ return None, f"utf-8 decode failed: {e}"
87
+
88
+
89
+ def parse_line(raw: str) -> tuple[bool | None, dict | None]:
90
+ """單行 → (ok, obj)。ok=None=空白行;ok=False=壞 JSON;ok=True=正常。去前置 BOM。"""
91
+ s = raw.rstrip("\r\n").lstrip(BOM)
92
+ if s.strip() == "":
93
+ return None, None
94
+ try:
95
+ obj = json.loads(s)
96
+ except Exception: # noqa: BLE001
97
+ return False, None
98
+ if not isinstance(obj, dict):
99
+ return False, None
100
+ return True, obj
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Line:
105
+ """一行的行身分。ok=False 代表壞 JSON 行(damaged 候選)。"""
106
+
107
+ index: int
108
+ ok: bool
109
+ obj: dict | None
110
+ uuid: str | None
111
+ parent: str | None
112
+ ts: str | None
113
+ type: str | None
114
+ is_sidechain: bool
115
+ canon_hash: str | None
116
+
117
+ @property
118
+ def identity(self) -> tuple[str | None, str | None]:
119
+ """行身分:有 uuid 用 (uuid, hash);無 uuid 用 (None, content-hash)。"""
120
+ return (self.uuid, self.canon_hash) if self.uuid else (None, self.canon_hash)
121
+
122
+ @property
123
+ def is_tool_fanout(self) -> bool:
124
+ """平行工具 fan-out 行:type=user 且帶 toolUseResult(非真 tip)。"""
125
+ return self.type == "user" and isinstance(self.obj, dict) and "toolUseResult" in self.obj
126
+
127
+
128
+ @dataclass
129
+ class LoadResult:
130
+ state: FileState
131
+ lines: list[Line]
132
+ decode_error: str | None = None
133
+
134
+ @property
135
+ def has_bad(self) -> bool:
136
+ return any(not ln.ok for ln in self.lines)
137
+
138
+ @property
139
+ def ok_lines(self) -> list[Line]:
140
+ return [ln for ln in self.lines if ln.ok]
141
+
142
+
143
+ def _line_from_obj(index: int, obj: dict) -> Line:
144
+ return Line(
145
+ index=index,
146
+ ok=True,
147
+ obj=obj,
148
+ uuid=obj.get("uuid"),
149
+ parent=obj.get("parentUuid"),
150
+ ts=obj.get("timestamp"),
151
+ type=obj.get("type"),
152
+ is_sidechain=bool(obj.get("isSidechain")),
153
+ canon_hash=canon_hash(obj),
154
+ )
155
+
156
+
157
+ def load(path: str) -> LoadResult:
158
+ """讀 jsonl → LoadResult。先判 file-state 三態,再逐行解析。"""
159
+ with open(path, "rb") as f:
160
+ raw = f.read()
161
+ return load_bytes(raw)
162
+
163
+
164
+ def load_bytes(raw: bytes) -> LoadResult:
165
+ """同 load 但吃 **bytes**(供「讀一次來源 bytes → 分類同一份 bytes → 寫同一份」綁定,transfer)。"""
166
+ if len(raw) == 0:
167
+ return LoadResult(FileState.ZERO_BYTE, [])
168
+ text, err = decode_bytes(raw)
169
+ if err is not None:
170
+ return LoadResult(FileState.DECODE_ERROR, [], err)
171
+ assert text is not None
172
+ if text.strip() == "":
173
+ return LoadResult(FileState.BLANK, [])
174
+
175
+ lines: list[Line] = []
176
+ norm = text.replace("\r\n", "\n").replace("\r", "\n")
177
+ for i, raw_ln in enumerate(norm.split("\n")):
178
+ ok, obj = parse_line(raw_ln)
179
+ if ok is None:
180
+ continue
181
+ if ok and obj is not None:
182
+ lines.append(_line_from_obj(i, obj))
183
+ else:
184
+ lines.append(Line(i, False, None, None, None, None, None, False, None))
185
+ return LoadResult(FileState.OK, lines)