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.
- claude_code_session_sync-0.1.0.dist-info/METADATA +87 -0
- claude_code_session_sync-0.1.0.dist-info/RECORD +31 -0
- claude_code_session_sync-0.1.0.dist-info/WHEEL +5 -0
- claude_code_session_sync-0.1.0.dist-info/entry_points.txt +2 -0
- claude_code_session_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- claude_code_session_sync-0.1.0.dist-info/top_level.txt +1 -0
- claude_session_sync/__init__.py +11 -0
- claude_session_sync/acks.py +279 -0
- claude_session_sync/anomaly.py +161 -0
- claude_session_sync/apply.py +874 -0
- claude_session_sync/atomicio.py +621 -0
- claude_session_sync/bootstrap.py +370 -0
- claude_session_sync/canonical.py +185 -0
- claude_session_sync/classify.py +133 -0
- claude_session_sync/cli.py +1065 -0
- claude_session_sync/config.py +128 -0
- claude_session_sync/doctor.py +351 -0
- claude_session_sync/fuzzy.py +136 -0
- claude_session_sync/lineset.py +143 -0
- claude_session_sync/memory.py +953 -0
- claude_session_sync/merge.py +836 -0
- claude_session_sync/pathsafe.py +91 -0
- claude_session_sync/py.typed +0 -0
- claude_session_sync/resolve.py +226 -0
- claude_session_sync/scan.py +485 -0
- claude_session_sync/session_merge.py +214 -0
- claude_session_sync/sidecar.py +238 -0
- claude_session_sync/snapshot.py +136 -0
- claude_session_sync/state.py +240 -0
- claude_session_sync/tombstone.py +330 -0
- claude_session_sync/transfer.py +462 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""scan:把基礎模組串成 dry-run 計畫(P1a 成品)。掃描→專案同一性→配對 session→分類→標單邊。
|
|
2
|
+
|
|
3
|
+
依據 DESIGN §6/§8 + PLAN v0.6 §2.9 / §3 資料流。**P1a 唯讀**:只產 SyncPlan、不寫檔。
|
|
4
|
+
- 專案同一性:local 專案夾讀某 session 的 `cwd` → git 指紋 → 比對 hub `_project.json`(可注入 identity_fn 測)。
|
|
5
|
+
- session 以**檔名**配對(B6);成對 → classify;單邊 → 查 hub tombstone(suppress)/ coverage(未 init→blocked)/ 否則 copy。
|
|
6
|
+
- first-run(無 state)標示;不在此寫 baseline(bootstrap 是 P1b)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from . import anomaly, memory, sidecar, tombstone
|
|
16
|
+
from .anomaly import Anomaly
|
|
17
|
+
# 跨側 presence/safety predicates 已上提到 anomaly(leaf,memory classify 也用);以原名 re-export 維持
|
|
18
|
+
# 既有呼叫端不變(transfer/doctor 用 `scan._collision_casefolds`;apply/test 用 `scan.is_bulk_local_deletion`)。
|
|
19
|
+
from .anomaly import collision_casefolds as _collision_casefolds # noqa: F401 (re-export)
|
|
20
|
+
from .anomaly import is_bulk_local_deletion # noqa: F401 (re-export)
|
|
21
|
+
from .classify import classify
|
|
22
|
+
from .lineset import analyze
|
|
23
|
+
# 逃逸防線移至 leaf `pathsafe`(單一真相源;anomaly 等 leaf 也能用、免循環,e2e gate2)。以原底線名 re-export
|
|
24
|
+
# 維持既有 `scan._safe_project_dir`/`_within_root`/`_list_project_dirs` 呼叫端不變。
|
|
25
|
+
from .pathsafe import dir_scannable as _dir_scannable # noqa: F401 (re-export;apply/transfer/bootstrap/doctor 用)
|
|
26
|
+
from .pathsafe import list_project_dirs as _list_project_dirs # noqa: F401 (re-export)
|
|
27
|
+
from .pathsafe import safe_project_dir as _safe_project_dir # noqa: F401 (re-export)
|
|
28
|
+
from .pathsafe import within_root as _within_root # noqa: F401 (re-export)
|
|
29
|
+
from .pathsafe import name_key as _name_key # noqa: F401 (re-export;NFC∘casefold∘NFC 單一真相源在 pathsafe,anomaly/memory 亦 import)
|
|
30
|
+
from .sidecar import MatchStatus
|
|
31
|
+
from .state import State
|
|
32
|
+
|
|
33
|
+
# local 端 session 根 = `<設定根>/projects`。設定根依 Claude Code 慣例取 `CLAUDE_CONFIG_DIR` env(多帳號/
|
|
34
|
+
# 設定不在預設位置者設此,與 Claude Code 本身一致);未設(最普遍)→ 預設 `~/.claude`。由 CLI `--local-root`
|
|
35
|
+
# 覆寫。此路徑下的 junction/symlink 由 OS 透明跟隨——使用者多帳號常以 directory junction 在**同機**刻意共用
|
|
36
|
+
# `projects/`/`memory/`(免權限、讀寫透明=同一夾),工具**信任** `CLAUDE_CONFIG_DIR` 指向的位置、不另偵測
|
|
37
|
+
# 共用別名(同份資料只配一個根、由使用者自己用 env 決定指向哪個帳號)。
|
|
38
|
+
def default_local_root() -> Path:
|
|
39
|
+
cfg_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
40
|
+
base = Path(cfg_dir) if cfg_dir else Path.home() / ".claude"
|
|
41
|
+
return base / "projects"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SessionPlan:
|
|
46
|
+
session_id: str
|
|
47
|
+
action: str # classify 類別值 或 copy-to-hub/copy-to-local/suppressed-deleted/
|
|
48
|
+
# conflict-delete-vs-update/blocked-uninitialized…
|
|
49
|
+
direction: str | None
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ProjectPlan:
|
|
55
|
+
local_dir: str | None
|
|
56
|
+
hub_dir: str | None
|
|
57
|
+
identity: str # match/ambiguous/needs-map/hub-only/local-only
|
|
58
|
+
coverage_initialized: bool
|
|
59
|
+
sessions: list[SessionPlan] = field(default_factory=list)
|
|
60
|
+
memories: list[memory.MemoryPlan] = field(default_factory=list) # P1d Block 3b:memory 檔級計畫
|
|
61
|
+
notes: list[str] = field(default_factory=list)
|
|
62
|
+
memory_scan_failed: bool = False # memory 規劃被跳過(memory/ symlink/不可讀)→ memories 不可信為「全部」
|
|
63
|
+
# (結構化旗標,非靠 note 文字;memory-merge 須據此 surface + 非零,不可 recheck 成功就抹掉 plan 的跳過事實,gate4 F3)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class SyncPlan:
|
|
68
|
+
first_run: bool
|
|
69
|
+
anomalies: list[Anomaly]
|
|
70
|
+
projects: list[ProjectPlan]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def halt(self) -> bool:
|
|
74
|
+
return any(a.severity == "halt" for a in self.anomalies)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _session_files(d: Path | None) -> dict[str, Path]:
|
|
78
|
+
"""專案夾下的 <sid>.jsonl(檔名 stem = sessionId)。略過 memory/、dotfiles 與 **symlink 檔**(後者對稱
|
|
79
|
+
`memory.list_memory_files`:`secret.jsonl` symlink → 夾外檔會被當 session 讀/copy 進 hub=洩漏,e2e gate2 #2;
|
|
80
|
+
`is_symlink()` 先於 `is_file()`——is_file 會跟隨 symlink)。真實 session 為實體檔、不受影響。"""
|
|
81
|
+
out: dict[str, Path] = {}
|
|
82
|
+
if d and d.exists():
|
|
83
|
+
for p in d.glob("*.jsonl"):
|
|
84
|
+
if not p.is_symlink() and p.is_file() and not p.name.startswith("."):
|
|
85
|
+
out[p.stem] = p
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _symlink_name_keys(d: Path | None) -> set[str]:
|
|
90
|
+
"""`d` 內 symlink leaf 的 `_name_key` 集,供 symlink-alias 偵測(e2e gate7/8/9)。`list_*_files` 略過 symlink →
|
|
91
|
+
該 name「看似 absent」;exact-name guard 漏掉 casefold/normalization-alias(`A.md`、NFD `café.md`、大寫 UUID)→
|
|
92
|
+
誤驅動 local-deleted tombstone / 把 alias symlink 當 absent 覆蓋。**呼叫端須先確保 `d` 安全**(apply 對 memory
|
|
93
|
+
根以 `_is_unfollowable_reparse` 過濾後才呼叫;session/transfer 的專案夾已由 `_safe_project_dir` 驗非 symlink 根)。
|
|
94
|
+
缺夾/讀不到 → 空集。"""
|
|
95
|
+
if d is None:
|
|
96
|
+
return set()
|
|
97
|
+
try:
|
|
98
|
+
return {_name_key(p.name) for p in Path(d).iterdir() if p.is_symlink()}
|
|
99
|
+
except OSError:
|
|
100
|
+
return set()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _project_cwds(local_dir: Path) -> set[str]:
|
|
106
|
+
"""收集該專案夾**所有** session 的 cwd。夾名映射有損,同夾可能混入多個 cwd(§3.1/§8.2)。"""
|
|
107
|
+
cwds: set[str] = set()
|
|
108
|
+
for p in sorted(_session_files(local_dir).values()):
|
|
109
|
+
for ln in analyze(str(p)).lines:
|
|
110
|
+
if ln.obj and ln.obj.get("cwd"):
|
|
111
|
+
cwds.add(ln.obj["cwd"])
|
|
112
|
+
break # 一個 session 取一個 cwd 即可
|
|
113
|
+
return cwds
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _git_identity(local_dir: Path, hub_dirs: list[Path]) -> tuple[str, Path | None]:
|
|
117
|
+
"""預設同一性解析:local cwd 的 git 指紋 vs 各 hub `_project.json`。判不出 → needs-map(不猜)。"""
|
|
118
|
+
cwds = _project_cwds(local_dir)
|
|
119
|
+
if len(cwds) > 1:
|
|
120
|
+
return ("blocked-multi-cwd", None) # 同夾多 cwd → 不可挑第一個,整夾阻斷(C-r6-1)
|
|
121
|
+
if not cwds:
|
|
122
|
+
return ("needs-map", None)
|
|
123
|
+
fp = sidecar.local_fingerprint(next(iter(cwds)))
|
|
124
|
+
exact: list[Path] = []
|
|
125
|
+
ambiguous = False
|
|
126
|
+
for hd in hub_dirs:
|
|
127
|
+
scd = sidecar.read_project_sidecar(hd)
|
|
128
|
+
if not scd:
|
|
129
|
+
continue
|
|
130
|
+
status = sidecar.match(fp, scd).status
|
|
131
|
+
if status == MatchStatus.MATCH:
|
|
132
|
+
exact.append(hd)
|
|
133
|
+
elif status == MatchStatus.AMBIGUOUS:
|
|
134
|
+
ambiguous = True
|
|
135
|
+
if len(exact) == 1:
|
|
136
|
+
return ("match", exact[0])
|
|
137
|
+
if len(exact) > 1 or ambiguous:
|
|
138
|
+
return ("ambiguous", None) # 多 exact 或 first-commit 相同不同 remote(fork/rename)→ 交人
|
|
139
|
+
return ("needs-map", None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _bindings_first(
|
|
143
|
+
resolve: Callable[[Path, list[Path]], tuple[str, Path | None]],
|
|
144
|
+
state: State | None,
|
|
145
|
+
) -> Callable[[Path, list[Path]], tuple[str, Path | None]]:
|
|
146
|
+
"""先採 state 的持久綁定(cwd→hub project_key,由 bootstrap/`--map` 寫入,A17.4),再退回 git 指紋。
|
|
147
|
+
|
|
148
|
+
使用者明示的綁定**優先於**啟發式 git 指紋。綁定指向的 hub 夾若當前不在(掛錯碟/被刪)→ needs-map,
|
|
149
|
+
不憑空配對。多 cwd 同夾不採綁定(夾名有損,交原解析判 blocked-multi-cwd)。
|
|
150
|
+
**空夾**(session 全刪 → 無 cwd 可解析身分)改採持久化的**夾名綁定**(local_dir_bindings),否則
|
|
151
|
+
「刪到空」的專案無法配對 → 對稱刪除偵測抓不到(codex r25)。夾名為 FS 既有路徑、不做編碼弱猜(決定#7)。
|
|
152
|
+
"""
|
|
153
|
+
bmap = (state.bindings if state else {}) or {}
|
|
154
|
+
dirmap = (state.local_dir_bindings if state else {}) or {}
|
|
155
|
+
if not bmap and not dirmap:
|
|
156
|
+
return resolve
|
|
157
|
+
|
|
158
|
+
def wrapped(local_dir: Path, hub_dirs: list[Path]) -> tuple[str, Path | None]:
|
|
159
|
+
cwds = _project_cwds(local_dir)
|
|
160
|
+
pk = None
|
|
161
|
+
if len(cwds) == 1:
|
|
162
|
+
pk = bmap.get(next(iter(cwds)))
|
|
163
|
+
elif len(cwds) == 0 and not _session_files(local_dir):
|
|
164
|
+
# **真正空夾**(無任何 session 檔)才用夾名綁定。有檔但讀不到 cwd(無 cwd 欄位/壞檔)→ **不**用
|
|
165
|
+
# 夾名誤配(否則 local-only 真檔可能寫進錯 hub / 誤 tombstone)→ 交原解析判 needs-map(codex r26-1)。
|
|
166
|
+
pk = dirmap.get(local_dir.name)
|
|
167
|
+
if pk:
|
|
168
|
+
for hd in hub_dirs:
|
|
169
|
+
if hd.name == pk:
|
|
170
|
+
return ("match", hd)
|
|
171
|
+
return ("needs-map", None) # 綁定的 hub 夾不在 → 不憑空配對
|
|
172
|
+
return resolve(local_dir, hub_dirs) # 多 cwd / 無綁定 → 原解析(git 指紋 / blocked-multi-cwd)
|
|
173
|
+
|
|
174
|
+
return wrapped
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _suppress_or_conflict(sid: str, lf: Path | None, hf: Path | None, tomb) -> SessionPlan:
|
|
178
|
+
"""tombstone 存在時的**條件式**判定(A3 delete-vs-update,P1c 消費 base_hash):
|
|
179
|
+
|
|
180
|
+
現存側內容(raw bytes digest)**全部 == base_hash** → suppress(不復活,尊重刪除);
|
|
181
|
+
否則(刪除後又被改 / 兩側內容不同 / base 不明)→ `conflict-delete-vs-update`:交人決策,
|
|
182
|
+
**既不復活、也不靜默壓掉刪除後的更新**。兩種結果都非自動套用 → apply 不寫(r14-1 不復活仍成立)。
|
|
183
|
+
"""
|
|
184
|
+
base = tomb.base_hash
|
|
185
|
+
present = [p for p in (lf, hf) if p is not None]
|
|
186
|
+
digests = [tombstone.raw_file_digest(p) for p in present]
|
|
187
|
+
if base is not None and digests and all(d == base for d in digests):
|
|
188
|
+
return SessionPlan(sid, "suppressed-deleted", None,
|
|
189
|
+
"hub tombstone 且現存內容==base → 不復活(A3)")
|
|
190
|
+
return SessionPlan(sid, "conflict-delete-vs-update", None,
|
|
191
|
+
"hub tombstone 但現存內容≠base(刪除後又改/兩側不一/base 不明)"
|
|
192
|
+
"→ 交人,不復活也不丟更新(A3)")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def classify_session(
|
|
196
|
+
sid: str, lf: Path | None, hf: Path | None, *,
|
|
197
|
+
both: bool, coverage_initialized: bool, tombs: dict,
|
|
198
|
+
is_collision: bool = False, corrupt: set | None = None, known: set | None = None,
|
|
199
|
+
has_baseline: bool = True, local_known: set | None = None,
|
|
200
|
+
bulk_local_deletion: bool = False, has_local_baseline: bool = True,
|
|
201
|
+
) -> SessionPlan:
|
|
202
|
+
"""單一 session 的分類(plan 與 apply-下重新分類共用同一套規則 → 不會漂移,codex r10-2)。
|
|
203
|
+
|
|
204
|
+
lf/hf = 該 sid 在 local/hub 的檔路徑(None 表該側無)。both = 專案是否兩側皆綁定。
|
|
205
|
+
corrupt = 該專案「壞掉的 tombstone」推定身分集(fail-closed,codex r11-3)。
|
|
206
|
+
known = 該專案 state 已知**hub** session 集(hub baseline),用於分辨「新檔」vs「已知檔被刪」(codex r16)。
|
|
207
|
+
has_baseline = **本機** state 是否已有此專案的基線(project_key ∈ known_sessions)。hub 的 coverage 是
|
|
208
|
+
別台 bootstrap 也可能有;單邊 copy 必須本機自己對此專案 bootstrap 過,否則分不清「新檔」與「對側已刪」
|
|
209
|
+
而可能復活刪除(codex r18)。
|
|
210
|
+
local_known = 該專案 state 已知**local** session 集(local baseline,P1c)。用於 hub 側單邊檔的對稱判定:
|
|
211
|
+
sid 曾在本機 local(∈local_known)但現已不在 → **本機刪除**(local-deleted),非新 hub 檔。
|
|
212
|
+
has_local_baseline = **本機** state 是否已有此專案的 local 基線(project_key ∈ local_sessions)。與
|
|
213
|
+
has_baseline(known/hub 基線)分開:舊 state(P1c 前)有 known 卻無 local_sessions → has_local_baseline
|
|
214
|
+
=False(**migration**)。此時無法分辨「新 hub 檔」與「本機已刪」→ present=hub 一律 fail-closed
|
|
215
|
+
`blocked-no-local-baseline`,**不 copy(避免靜默復活已刪)也不 tombstone**,待使用者重 bootstrap 建
|
|
216
|
+
local 基線並確認可匯入差異(codex r24-1)。empty 的 local_sessions[pk](has_local_baseline=True)≠ 無此
|
|
217
|
+
欄位:前者是真基線(該專案 local 當時為空),後者才是 migration。
|
|
218
|
+
bulk_local_deletion = 本專案 local 是否大量消失(疑掛錯碟/被清)→ local-deleted 改 blocked-bulk-local-deletion。
|
|
219
|
+
"""
|
|
220
|
+
if is_collision:
|
|
221
|
+
return SessionPlan(sid, "blocked-casefold-collision", None,
|
|
222
|
+
"casefold 撞名 sessionId(同側或跨側 case-only,跨 OS 碰撞風險,A9)")
|
|
223
|
+
# tombstone 閘**先於**配對分類(codex r14-1):刪除標記不論成對/單邊都該抑制,否則 tombstoned 的
|
|
224
|
+
# session 若兩側都還在、且 local 是 hub 的 ff,會被當 fast-forward 寫回 hub=復活已刪。
|
|
225
|
+
if ("session", sid) in tombs:
|
|
226
|
+
return _suppress_or_conflict(sid, lf, hf, tombs[("session", sid)])
|
|
227
|
+
if corrupt and ("session", sid) in corrupt:
|
|
228
|
+
return SessionPlan(sid, "blocked-tombstone-corrupt", None,
|
|
229
|
+
"tombstone 損壞、無法確認是否已刪 → 阻擋(fail-closed,不復活)")
|
|
230
|
+
if lf and hf:
|
|
231
|
+
c = classify(analyze(str(lf)), analyze(str(hf)))
|
|
232
|
+
return SessionPlan(sid, c.klass.value, c.direction, c.reason)
|
|
233
|
+
# 單邊存在
|
|
234
|
+
present = "local" if lf else "hub"
|
|
235
|
+
if not both:
|
|
236
|
+
# 無對側綁定(hub-only / local-only / 未對應)→ 不知落到哪 → 拒絕落地(C-r6-2)
|
|
237
|
+
return SessionPlan(sid, "blocked-unmapped", None,
|
|
238
|
+
"專案未對應到對側(需 --map / bootstrap),單邊不落地")
|
|
239
|
+
if not coverage_initialized:
|
|
240
|
+
return SessionPlan(sid, "blocked-uninitialized", None, "專案未 bootstrap,單邊檔不自動處理")
|
|
241
|
+
if not has_baseline:
|
|
242
|
+
return SessionPlan(sid, "blocked-no-baseline", None,
|
|
243
|
+
"本機未對此專案 bootstrap(hub 的 coverage 可能來自他機)→ 單邊檔不自動複製(避免復活刪除)")
|
|
244
|
+
# present=hub 另需 **local 基線**:migration(舊 state 有 known、無 local_sessions)下無從分辨「新 hub 檔」
|
|
245
|
+
# 與「本機已刪」→ fail-closed,不 copy(避免靜默復活)、不 tombstone,待重 bootstrap 建 local 基線(codex r24-1)。
|
|
246
|
+
if present == "hub" and not has_local_baseline:
|
|
247
|
+
return SessionPlan(sid, "blocked-no-local-baseline", None,
|
|
248
|
+
"本機無此專案 local 基線(疑舊 state 遷移)→ 單邊 hub 檔不自動處理,請重 bootstrap")
|
|
249
|
+
# 「已知 session 單邊消失(無 tombstone)」的**對稱**偵測,方向決定信任與否:
|
|
250
|
+
# - hub 側消失(present=local,sid∈known):hub 是永久歸檔、不該無故掉檔 → 不信任 → 交人
|
|
251
|
+
# (blocked-known-deleted),不可從 local copy 回 hub 復活(codex r16)。
|
|
252
|
+
# - local 側消失(present=hub,sid∈local_known):使用者刪自己的 local 是正常操作 → 信任 → 寫 hub
|
|
253
|
+
# tombstone 通知對側(local-deleted,apply 寫;不刪 hub 歸檔,A3)。但**大量**消失(疑掛錯碟/被清)
|
|
254
|
+
# → 不自動寫、整批交人(blocked-bulk-local-deletion)——false-positive 會寫 tombstone 抑制真實 session。
|
|
255
|
+
# 兩者都在 damaged 閘**之前**:tombstone/blocked 不需來源可讀(base_hash 走 raw bytes,壞檔亦可標記)。
|
|
256
|
+
if present == "local" and sid in (known or set()):
|
|
257
|
+
return SessionPlan(sid, "blocked-known-deleted", None,
|
|
258
|
+
"已知 session 在 hub 消失且無 tombstone(疑刪除,非新檔)→ 交人決策")
|
|
259
|
+
if present == "hub" and sid in (local_known or set()):
|
|
260
|
+
if bulk_local_deletion:
|
|
261
|
+
return SessionPlan(sid, "blocked-bulk-local-deletion", None,
|
|
262
|
+
"本專案 local 大量消失(疑掛錯碟/被清空)→ 不自動寫 tombstone,整批交人確認")
|
|
263
|
+
return SessionPlan(sid, "local-deleted", None,
|
|
264
|
+
"已知 local session 消失(本機刪除)→ 寫 hub tombstone 通知對側(不刪 hub 歸檔,A3)")
|
|
265
|
+
# 單邊 copy 來源也要過損壞閘(codex r14-2):0-byte/空白/解碼錯/壞行/無對話身分(含正在被寫的半截檔)
|
|
266
|
+
# 不可原樣複製到對側,否則把壞檔散播出去。
|
|
267
|
+
src = lf or hf
|
|
268
|
+
shape = analyze(str(src))
|
|
269
|
+
if shape.is_damaged or not shape.uuids:
|
|
270
|
+
return SessionPlan(sid, "blocked-damaged-source", None,
|
|
271
|
+
"單邊來源檔損壞/無對話身分(0-byte/壞行/空/可能正在寫),不複製")
|
|
272
|
+
action = "copy-to-hub" if present == "local" else "copy-to-local"
|
|
273
|
+
return SessionPlan(sid, action, f"{present}->other", f"單邊新檔({present})")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def casefold_collisions_for(local_dir: Path | None, hub_dir: Path | None) -> set[str]:
|
|
277
|
+
"""該專案兩側**合併**的 casefold 撞名集(供 apply 下重新分類重算 collision)。"""
|
|
278
|
+
return _collision_casefolds(_session_files(local_dir).keys(), _session_files(hub_dir).keys())
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def plan_project_pair(
|
|
282
|
+
local_dir: Path | None,
|
|
283
|
+
hub_dir: Path | None,
|
|
284
|
+
*,
|
|
285
|
+
coverage_initialized: bool,
|
|
286
|
+
tombs: dict | None = None,
|
|
287
|
+
corrupt: set | None = None,
|
|
288
|
+
known: set | None = None,
|
|
289
|
+
has_baseline: bool = True,
|
|
290
|
+
local_known: set | None = None,
|
|
291
|
+
has_local_baseline: bool = True,
|
|
292
|
+
) -> list[SessionPlan]:
|
|
293
|
+
"""單一(已配對)專案:逐 session 產動作。成對 classify;單邊查 tombstone/coverage/known/local_known。"""
|
|
294
|
+
tombs = tombs or {}
|
|
295
|
+
both = local_dir is not None and hub_dir is not None
|
|
296
|
+
local = _session_files(local_dir)
|
|
297
|
+
hub = _session_files(hub_dir)
|
|
298
|
+
collisions = _collision_casefolds(local.keys(), hub.keys())
|
|
299
|
+
bulk = is_bulk_local_deletion(local_known, set(local.keys()))
|
|
300
|
+
plans: list[SessionPlan] = []
|
|
301
|
+
for sid in sorted(set(local) | set(hub)):
|
|
302
|
+
plans.append(classify_session(
|
|
303
|
+
sid, local.get(sid), hub.get(sid), both=both,
|
|
304
|
+
coverage_initialized=coverage_initialized, tombs=tombs, corrupt=corrupt, known=known,
|
|
305
|
+
has_baseline=has_baseline, is_collision=sid.casefold() in collisions,
|
|
306
|
+
local_known=local_known, bulk_local_deletion=bulk, has_local_baseline=has_local_baseline,
|
|
307
|
+
))
|
|
308
|
+
return plans
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _plan_memories(
|
|
312
|
+
local_dir: Path | None, hub_dir: Path | None, *,
|
|
313
|
+
state: State | None, cov: bool, tombs: dict, corrupt: set,
|
|
314
|
+
) -> list[memory.MemoryPlan]:
|
|
315
|
+
"""單一專案的 memory 檔級計畫(對稱 `plan_project_pair`,P1d Block 3b)。memory 基線取自 state 的
|
|
316
|
+
`known_memory`/`local_memory`(與 session 的 known_sessions/local_sessions 對稱、各自獨立)。tombs/corrupt
|
|
317
|
+
沿用同一份(plan_memory_pair 內部自行篩 kind=="memory")。`memory.UnsafeMemoryDir` 由呼叫端 catch。"""
|
|
318
|
+
pk = hub_dir.name if hub_dir else None
|
|
319
|
+
mem_known = state.known_memory.get(pk) if (state and pk) else None
|
|
320
|
+
mem_has_baseline = bool(state and pk and pk in state.known_memory)
|
|
321
|
+
mem_local_known = state.local_memory.get(pk) if (state and pk) else None
|
|
322
|
+
mem_has_local_baseline = bool(state and pk and pk in state.local_memory)
|
|
323
|
+
return memory.plan_memory_pair(
|
|
324
|
+
local_dir, hub_dir, coverage_initialized=cov, tombs=tombs, corrupt=corrupt,
|
|
325
|
+
known=mem_known, has_baseline=mem_has_baseline,
|
|
326
|
+
local_known=mem_local_known, has_local_baseline=mem_has_local_baseline)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def build_plan(
|
|
330
|
+
local_root: str | Path,
|
|
331
|
+
hub_root: str | Path,
|
|
332
|
+
state: State | None,
|
|
333
|
+
*,
|
|
334
|
+
identity_fn: Callable[[Path, list[Path]], tuple[str, Path | None]] | None = None,
|
|
335
|
+
memory_only: bool = False,
|
|
336
|
+
) -> SyncPlan:
|
|
337
|
+
"""完整 dry-run 計畫。identity_fn 可注入(測試);預設用 git 指紋,並優先採 state 的持久綁定(A17.4)。
|
|
338
|
+
|
|
339
|
+
`memory_only=True`:**只算 memory 檔級計畫**,跳過每個 session 的 `plan_project_pair`(`sessions=[]`)。
|
|
340
|
+
供 `nudge` hook 助手(DESIGN §7.5「只比對 memory、不做重活」)在 SessionEnd/Start 輕量檢查記憶分歧用——
|
|
341
|
+
避免對每個 session JSONL 做完整分類(最重的一段)。**memory 計畫與 session 無關、結果不受影響**。
|
|
342
|
+
(身分解析仍需讀 session cwd 來配對 local↔hub 夾;完全免 session-parse 需夾名身分快路徑,留待需要時。)"""
|
|
343
|
+
local_root, hub_root = Path(local_root), Path(hub_root)
|
|
344
|
+
resolve = _bindings_first(identity_fn or _git_identity, state)
|
|
345
|
+
anomalies = anomaly.check(state, hub_root)
|
|
346
|
+
if any(a.severity == "halt" for a in anomalies):
|
|
347
|
+
return SyncPlan(first_run=state is None, anomalies=anomalies, projects=[])
|
|
348
|
+
|
|
349
|
+
# **逃逸專案夾過濾**(e2e gate G-High):symlink 或逃出 root 的 reparse 專案夾一律不讀/不寫、不跟隨——否則主
|
|
350
|
+
# sync 會把逃逸 local 夾的界外 session copy 進 hub(洩漏)、或把 hub 逃逸夾當本專案寫穿到界外。root 內 junction
|
|
351
|
+
# (ccdir 多帳號刻意共用)resolve 後仍在 root 內 → 照常允許。與 transfer/bootstrap/doctor 同一把 `_safe_project_dir`。
|
|
352
|
+
hub_dirs, hub_unsafe = _list_project_dirs(hub_root)
|
|
353
|
+
local_dirs, local_unsafe = _list_project_dirs(local_root)
|
|
354
|
+
|
|
355
|
+
projects: list[ProjectPlan] = []
|
|
356
|
+
# 逃逸夾**可見回報** skipped-unsafe(非靜默丟):使用者看得到、可改實體目錄;apply 對它們無 session/memory 可寫。
|
|
357
|
+
for ld in local_unsafe:
|
|
358
|
+
projects.append(ProjectPlan(
|
|
359
|
+
local_dir=str(ld), hub_dir=None, identity="skipped-unsafe", coverage_initialized=False,
|
|
360
|
+
notes=["local 專案夾是 symlink 或逃逸 local_root → 跳過(不讀/寫信任根外,請改實體目錄)"]))
|
|
361
|
+
for hd in hub_unsafe:
|
|
362
|
+
projects.append(ProjectPlan(
|
|
363
|
+
local_dir=None, hub_dir=str(hd), identity="skipped-unsafe", coverage_initialized=False,
|
|
364
|
+
notes=["hub 專案夾是 symlink 或逃逸 hub_root → 跳過(不讀/寫信任根外)"]))
|
|
365
|
+
matched_hub: set[Path] = set()
|
|
366
|
+
for ld in local_dirs:
|
|
367
|
+
status, hub_dir = resolve(ld, hub_dirs)
|
|
368
|
+
if hub_dir:
|
|
369
|
+
matched_hub.add(hub_dir)
|
|
370
|
+
cov = tombstone.is_initialized(hub_dir) if hub_dir else False
|
|
371
|
+
tombs = tombstone.read_tombstones(hub_dir) if hub_dir else {}
|
|
372
|
+
corrupt = tombstone.corrupt_tombstone_targets(hub_dir) if hub_dir else set()
|
|
373
|
+
known = (state.known_sessions.get(hub_dir.name) if (state and hub_dir) else None)
|
|
374
|
+
has_baseline = bool(state and hub_dir and hub_dir.name in state.known_sessions)
|
|
375
|
+
local_known = (state.local_sessions.get(hub_dir.name) if (state and hub_dir) else None)
|
|
376
|
+
has_local_baseline = bool(state and hub_dir and hub_dir.name in state.local_sessions)
|
|
377
|
+
notes = [] if hub_dir else [f"未對應到 hub 專案({status});需 --map / bootstrap"]
|
|
378
|
+
mem_scan_failed = False
|
|
379
|
+
try:
|
|
380
|
+
mem_plans = _plan_memories(ld, hub_dir, state=state, cov=cov, tombs=tombs, corrupt=corrupt)
|
|
381
|
+
except memory.UnsafeMemoryDir:
|
|
382
|
+
mem_plans, mem_scan_failed = [], True
|
|
383
|
+
notes.append("memory/ 根是 symlink → 已跳過記憶同步(請改實體目錄)")
|
|
384
|
+
except OSError as e:
|
|
385
|
+
# memory/ 夾不可讀(權限/陳舊掛載等)→ 不讓它崩掉整個 plan(否則 sync/status/memory-merge 全炸、
|
|
386
|
+
# memory-merge 還來不及 surface 就 crash,gate3 F2);跳過該專案 memory、記 note + 旗標,由 unscannable 回報。
|
|
387
|
+
mem_plans, mem_scan_failed = [], True
|
|
388
|
+
notes.append(f"memory/ 夾無法讀取({e.__class__.__name__})→ 已跳過記憶同步")
|
|
389
|
+
projects.append(
|
|
390
|
+
ProjectPlan(
|
|
391
|
+
local_dir=str(ld), hub_dir=str(hub_dir) if hub_dir else None,
|
|
392
|
+
identity=status,
|
|
393
|
+
coverage_initialized=cov,
|
|
394
|
+
sessions=([] if memory_only else
|
|
395
|
+
plan_project_pair(ld, hub_dir, coverage_initialized=cov, tombs=tombs,
|
|
396
|
+
corrupt=corrupt, known=known, has_baseline=has_baseline,
|
|
397
|
+
local_known=local_known, has_local_baseline=has_local_baseline)),
|
|
398
|
+
memories=mem_plans,
|
|
399
|
+
notes=notes,
|
|
400
|
+
memory_scan_failed=mem_scan_failed,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
for hd in hub_dirs:
|
|
405
|
+
if hd in matched_hub:
|
|
406
|
+
continue
|
|
407
|
+
cov = tombstone.is_initialized(hd)
|
|
408
|
+
tombs = tombstone.read_tombstones(hd)
|
|
409
|
+
corrupt = tombstone.corrupt_tombstone_targets(hd)
|
|
410
|
+
known = state.known_sessions.get(hd.name) if state else None
|
|
411
|
+
has_baseline = bool(state and hd.name in state.known_sessions)
|
|
412
|
+
hub_notes = ["hub 有、local 無此專案"]
|
|
413
|
+
mem_scan_failed = False
|
|
414
|
+
try:
|
|
415
|
+
mem_plans = _plan_memories(None, hd, state=state, cov=cov, tombs=tombs, corrupt=corrupt)
|
|
416
|
+
except memory.UnsafeMemoryDir:
|
|
417
|
+
mem_plans, mem_scan_failed = [], True
|
|
418
|
+
hub_notes.append("memory/ 根是 symlink → 已跳過記憶同步")
|
|
419
|
+
except OSError as e: # 不可讀 memory/ 夾 → 不崩整 plan(gate3 F2)
|
|
420
|
+
mem_plans, mem_scan_failed = [], True
|
|
421
|
+
hub_notes.append(f"memory/ 夾無法讀取({e.__class__.__name__})→ 已跳過記憶同步")
|
|
422
|
+
projects.append(
|
|
423
|
+
ProjectPlan(
|
|
424
|
+
local_dir=None, hub_dir=str(hd), identity="hub-only",
|
|
425
|
+
coverage_initialized=cov,
|
|
426
|
+
sessions=([] if memory_only else
|
|
427
|
+
plan_project_pair(None, hd, coverage_initialized=cov, tombs=tombs,
|
|
428
|
+
corrupt=corrupt, known=known, has_baseline=has_baseline)),
|
|
429
|
+
memories=mem_plans,
|
|
430
|
+
notes=hub_notes,
|
|
431
|
+
memory_scan_failed=mem_scan_failed,
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return SyncPlan(first_run=state is None, anomalies=anomalies, projects=projects)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# 「工具永遠無法自動解決、可由使用者 A15 acknowledge」的 blocked action 集(單一真相源;acks.ACKABLE_ACTIONS
|
|
439
|
+
# re-export 之——acks import scan、不可反向)。**呈現層隱藏必守此護欄**:只藏這些 action 的行,絕不因某 sid 被 ack
|
|
440
|
+
# 就連同一 sid 在另一 local view 的 copy-to-hub/fork 等**非 ackable** 行一起藏(fresh gate g3 High)。
|
|
441
|
+
ACKABLE_ACTIONS = frozenset({
|
|
442
|
+
"blocked-casefold-collision", "blocked-damaged-source", "damaged", "identity-collision"})
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def format_plan(plan: SyncPlan, ack_view=None) -> str:
|
|
446
|
+
"""把 SyncPlan 渲染成 status/dry-run 文字。
|
|
447
|
+
|
|
448
|
+
`ack_view`(`acks.AckView`,選配)=**純呈現層過濾**:隱藏使用者已 `doctor --ack` 的 damaged/collision 行、
|
|
449
|
+
附「N 項已 acknowledged」摘要(DESIGN A15)。**只影響顯示**——分類(`build_plan`/`classify`)與寫入(`apply`)
|
|
450
|
+
完全不看它,acked 項仍為 blocked、apply 一律不碰(結構上不可能因 ack 而 auto-apply)。"""
|
|
451
|
+
lines: list[str] = []
|
|
452
|
+
if plan.first_run:
|
|
453
|
+
lines.append("⚠ 首次同步(無 state):請先 `--bootstrap` 建基線;以下為唯讀預覽。")
|
|
454
|
+
for a in plan.anomalies:
|
|
455
|
+
lines.append(f"[{a.severity.upper()}] {a.code}: {a.message}")
|
|
456
|
+
if plan.halt:
|
|
457
|
+
lines.append("→ 偵測到 halt 級異常,停止(不進行任何寫入)。")
|
|
458
|
+
return "\n".join(lines)
|
|
459
|
+
for pp in plan.projects:
|
|
460
|
+
head = pp.hub_dir or pp.local_dir
|
|
461
|
+
lines.append(f"\n專案 {head} [{pp.identity}{'' if pp.coverage_initialized else ', 未bootstrap'}]")
|
|
462
|
+
for n in pp.notes:
|
|
463
|
+
lines.append(f" · {n}")
|
|
464
|
+
pk = Path(pp.hub_dir).name if pp.hub_dir else None
|
|
465
|
+
hidden = ack_view.hidden.get(pk, frozenset()) if (ack_view and pk) else frozenset()
|
|
466
|
+
n_hidden = 0
|
|
467
|
+
for s in pp.sessions:
|
|
468
|
+
# 護欄:**同時**要求 sid 已 ack **且** action 為 ackable——否則同 sid 在另一 local view 的
|
|
469
|
+
# copy-to-hub/fork 等非 ackable 行會被 ack 誤藏(漏顯示待寫入,g3 High);對稱 apply.format_report。
|
|
470
|
+
if s.session_id in hidden and s.action in ACKABLE_ACTIONS:
|
|
471
|
+
n_hidden += 1
|
|
472
|
+
continue # 已 ack 的 ackable blocked 行 → 呈現層隱藏(分類仍 blocked、apply 不動)
|
|
473
|
+
d = f" ({s.direction})" if s.direction else ""
|
|
474
|
+
# 寫 local 既有檔的動作(codex r6):P1a 只標示;P1b 不直接覆蓋
|
|
475
|
+
writes_local = s.action == "copy-to-local" or s.direction == "hub->local"
|
|
476
|
+
caveat = " ⚠P1b:寫 local 前過 active 檢查,無法確認未開啟則 keep-both/quarantine" if writes_local else ""
|
|
477
|
+
lines.append(f" - {s.session_id[:8]}: {s.action}{d} — {s.reason}{caveat}")
|
|
478
|
+
if not pp.sessions:
|
|
479
|
+
lines.append(" (無 session)")
|
|
480
|
+
if n_hidden:
|
|
481
|
+
lines.append(f" · ({n_hidden} 項 damaged/collision 已 acknowledged;doctor --show-acked 可查)")
|
|
482
|
+
for m in pp.memories:
|
|
483
|
+
md = f" ({m.direction})" if m.direction else ""
|
|
484
|
+
lines.append(f" - memory {m.name}: {m.action}{md} — {m.reason}")
|
|
485
|
+
return "\n".join(lines) if lines else "(無可同步項)"
|