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,1065 @@
1
+ """CLI:`status`(唯讀)/ `sync`(dry-run 預設,`--apply` 安全寫入)/ `bootstrap`(建基線)。
2
+
3
+ dry-run 為預設。`--apply` 自動套用僅 identical/paired-ff/copy(其餘只回報,互動/刪除是 P1c)。
4
+ 退出碼:0 正常;1 錯誤(前置/用法/寫入);2 halt 級異常。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import contextlib
10
+ import io
11
+ import json
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from . import acks as acks_mod
18
+ from . import apply as apply_mod
19
+ from . import atomicio as atomicio_mod
20
+ from . import bootstrap as bootstrap_mod
21
+ from . import config as config_mod
22
+ from . import doctor as doctor_mod
23
+ from . import fuzzy as fuzzy_mod
24
+ from . import memory as memory_mod
25
+ from . import merge as merge_mod
26
+ from . import resolve as resolve_mod
27
+ from . import scan
28
+ from . import state as state_mod
29
+ from . import transfer as transfer_mod
30
+ from .scan import default_local_root
31
+ from .session_merge import MergeOutcome
32
+
33
+
34
+ @dataclass
35
+ class Context:
36
+ config: config_mod.Config
37
+ hub: str
38
+ local_root: str
39
+ state_path: str | None
40
+ state: state_mod.State | None
41
+
42
+
43
+ def _add_common(sp: argparse.ArgumentParser) -> None:
44
+ sp.add_argument("--hub", help="覆寫 own_hub 路徑(預設讀 config)")
45
+ sp.add_argument("--local-root",
46
+ help="覆寫 local session 根(預設 $CLAUDE_CONFIG_DIR/projects,未設則 ~/.claude/projects)")
47
+ sp.add_argument("--state", help="覆寫 state.json 路徑")
48
+
49
+
50
+ def _resolve_context(args) -> tuple[Context | None, str | None]:
51
+ try:
52
+ cfg = config_mod.load()
53
+ except config_mod.ConfigError as e:
54
+ return None, f"config.toml 損壞,保守中止:{e}"
55
+ hub = args.hub or cfg.own_hub
56
+ if not hub:
57
+ return None, "尚未設定 own_hub:請先 `config set own-hub <path>` 或用 --hub。"
58
+ try:
59
+ st = state_mod.load_or_none(args.state)
60
+ except state_mod.StateCorruptError as e:
61
+ return None, f"state.json 損壞,保守中止(不可當首次同步):{e}\n 可用 doctor --rebuild-state(P1c)。"
62
+ local_root = args.local_root or str(default_local_root())
63
+ state_path = args.state or str(state_mod.default_state_path())
64
+ return Context(config=cfg, hub=hub, local_root=local_root, state_path=state_path, state=st), None
65
+
66
+
67
+ def _stdin_decider(ctx: resolve_mod.ResolveContext) -> resolve_mod.Decision:
68
+ """CLI 互動 decider:對每個 fork/superset 問 [u]nion / keep-[b]oth / [s]kip。"""
69
+ can_union = ctx.union_outcome != MergeOutcome.FALLBACK
70
+ print(f"\n fork/superset: {ctx.session_id[:8]}({ctx.action})")
71
+ menu = (["[u] union 合併兩枝"] if can_union else [f"(union 不可用:{ctx.union_reason})"]) + \
72
+ ["[b] keep-both(把 hub 分枝帶進 local)", "[s] 跳過"]
73
+ print(" " + " ".join(menu))
74
+ ans = input(" 選擇 [u/b/s]: ").strip().lower()
75
+ if ans == "u" and can_union:
76
+ tip = None
77
+ if ctx.union_outcome == MergeOutcome.NEEDS_DECISION:
78
+ print(" 需選 active tip:")
79
+ for i, lf in enumerate(ctx.leaves):
80
+ print(f" ({i}) {lf.uuid[:8]} ts={lf.ts}")
81
+ try:
82
+ tip = ctx.leaves[int(input(" tip 編號: ").strip())].uuid
83
+ except (ValueError, IndexError):
84
+ print(" 無效編號 → 跳過")
85
+ return resolve_mod.Decision(resolve_mod.Choice.SKIP)
86
+ return resolve_mod.Decision(resolve_mod.Choice.UNION, chosen_tip=tip)
87
+ if ans == "b":
88
+ return resolve_mod.Decision(resolve_mod.Choice.KEEP_BOTH)
89
+ return resolve_mod.Decision(resolve_mod.Choice.SKIP)
90
+
91
+
92
+ def _cmd_status_or_sync(args) -> int:
93
+ ctx, err = _resolve_context(args)
94
+ if err:
95
+ print(err, file=sys.stderr)
96
+ return 1
97
+ assert ctx is not None
98
+ plan = scan.build_plan(ctx.local_root, ctx.hub, ctx.state)
99
+ ack_view = acks_mod.compute_ack_view(plan) # A15:純呈現層過濾(隱藏已 ack 的 damaged/collision)
100
+ print(scan.format_plan(plan, ack_view))
101
+ if ack_view.corrupt_projects:
102
+ print(f"⚠ acks.json 損壞(已忽略、全部照常回報):{', '.join(sorted(ack_view.corrupt_projects))}",
103
+ file=sys.stderr)
104
+ if plan.halt:
105
+ return 2
106
+ interactive = getattr(args, "interactive", False)
107
+ if getattr(args, "apply", False):
108
+ report = apply_mod.apply_plan( # 首次同步(無 state)會在此 halt,要求先 bootstrap
109
+ plan, local_root=ctx.local_root, hub_root=ctx.hub, config=ctx.config,
110
+ state=ctx.state, state_path=ctx.state_path,
111
+ )
112
+ print("\n=== apply ===")
113
+ # g5 High:apply 報告用 **apply 後 fresh** ack_view(重讀磁碟重算),**不**沿用 dry-run(T0) 的 stale 視圖——
114
+ # apply_plan 在 T2 重讀/重分類,若某 blocked 檔在 plan→apply 間變成新 blocked 情況,apply 已報告它、卻可能被
115
+ # 舊 ack 遮蓋。acked 項為 blocked、apply 不寫其檔 → apply 後(T3)以現況重算指紋判定精確、不誤藏變化。
116
+ report_ack_view = acks_mod.compute_ack_view(scan.build_plan(ctx.local_root, ctx.hub, ctx.state))
117
+ print(apply_mod.format_report(report, report_ack_view))
118
+ if report.halted:
119
+ return 2
120
+ resolve_error = False
121
+ if interactive: # 對 apply 只回報的 fork/superset 跑互動 union/keep-both
122
+ rreport = resolve_mod.resolve_plan(
123
+ plan, hub_root=ctx.hub, state=ctx.state, state_path=ctx.state_path,
124
+ decider=_stdin_decider,
125
+ )
126
+ print("\n=== 互動解決 fork/superset ===")
127
+ print(resolve_mod.format_report(rreport))
128
+ if rreport.halted:
129
+ return 2
130
+ resolve_error = rreport.had_error # 互動寫入錯誤也算未竟全功(誠實非零)
131
+ if report.had_error or report.had_uncommitted or report.reconcile_failed or resolve_error:
132
+ return 1
133
+ elif interactive:
134
+ print("\n(--interactive 需搭配 --apply 才會寫入;本次僅 dry-run,未處理 fork/superset)")
135
+ return 0
136
+
137
+
138
+ def _parse_maps(items: list[str] | None) -> tuple[dict[str, str], str | None]:
139
+ out: dict[str, str] = {}
140
+ for it in items or []:
141
+ if "=" not in it:
142
+ return {}, f"--map 需 <local夾名>=<hub夾名> 格式:{it}"
143
+ k, v = it.split("=", 1)
144
+ if not k or not v:
145
+ return {}, f"--map 兩側不可空:{it}"
146
+ out[k] = v
147
+ return out, None
148
+
149
+
150
+ def _cmd_bootstrap(args) -> int:
151
+ ctx, err = _resolve_context(args)
152
+ if err:
153
+ print(err, file=sys.stderr)
154
+ return 1
155
+ assert ctx is not None
156
+ mappings, merr = _parse_maps(args.map)
157
+ if merr:
158
+ print(merr, file=sys.stderr)
159
+ return 1
160
+ ignore = set(args.ignore or [])
161
+ plan = bootstrap_mod.scan_baseline(
162
+ ctx.local_root, ctx.hub, ctx.state, mappings=mappings, ignore=ignore
163
+ )
164
+ print(bootstrap_mod.format_baseline(plan))
165
+ if plan.halt:
166
+ return 2
167
+ if not args.yes:
168
+ print("\n(預覽)確認以上單邊檔可匯入後,加 --yes 落地基線(不複製/不刪 session)。")
169
+ return 0
170
+ if not plan.mapped:
171
+ print("\n無可建基線的已配對專案;未寫入。", file=sys.stderr)
172
+ return 1
173
+ try:
174
+ summary = bootstrap_mod.apply_baseline(plan, ctx.hub, ctx.state_path)
175
+ except bootstrap_mod.BootstrapChanged as e:
176
+ print(f"\n中止:{e}", file=sys.stderr)
177
+ return 1
178
+ print(f"\n已建基線:{len(summary['blessed_projects'])} 個專案"
179
+ f";session tombstone {len(summary['tombstoned'])} 條"
180
+ f";memory tombstone {len(summary.get('mem_tombstoned', []))} 條。")
181
+ return 0
182
+
183
+
184
+ def _cmd_transfer(args, direction: str) -> int:
185
+ """跨群 pull/push(DESIGN §8.1):dry-run 預設、--apply 寫入;remote 名由 config `[remotes]` 解析。"""
186
+ try:
187
+ cfg = config_mod.load()
188
+ except config_mod.ConfigError as e:
189
+ print(f"config.toml 損壞,保守中止:{e}", file=sys.stderr)
190
+ return 1
191
+ name = args.from_remote if direction == transfer_mod.PULL else args.to_remote
192
+ flag = "--from" if direction == transfer_mod.PULL else "--to"
193
+ if not name:
194
+ print(f"請指定 {flag} <remote 名稱>", file=sys.stderr)
195
+ return 1
196
+ remote_path = cfg.remotes.get(name)
197
+ if not remote_path:
198
+ have = ", ".join(sorted(cfg.remotes)) or "(無)"
199
+ print(f"未知 remote '{name}';先 `remote add {name} <path>`。現有:{have}", file=sys.stderr)
200
+ return 1
201
+ mappings, merr = _parse_maps(args.map)
202
+ if merr:
203
+ print(merr, file=sys.stderr)
204
+ return 1
205
+ local_root = args.local_root or str(default_local_root())
206
+ plan = transfer_mod.plan_transfer(direction, local_root, remote_path, remote_name=name,
207
+ session=args.session, mappings=mappings)
208
+ print(transfer_mod.format_plan(plan))
209
+ if plan.halt:
210
+ return 2
211
+ if getattr(args, "apply", False):
212
+ report = transfer_mod.apply_transfer(plan, local_root=local_root, remote_root=remote_path)
213
+ print("\n=== apply ===")
214
+ print(transfer_mod.format_report(report))
215
+ if report.halted:
216
+ return 2
217
+ if report.had_error:
218
+ return 1
219
+ return 0
220
+
221
+
222
+ def _cmd_remote(args) -> int:
223
+ """管理跨群 remote hub(寫 config `[remotes]`)。"""
224
+ try:
225
+ cfg = config_mod.load()
226
+ except config_mod.ConfigError as e:
227
+ print(f"config.toml 損壞,保守中止:{e}", file=sys.stderr)
228
+ return 1
229
+ if args.remote_cmd == "list":
230
+ if not cfg.remotes:
231
+ print("(無 remote)")
232
+ for n in sorted(cfg.remotes):
233
+ print(f"{n} = {cfg.remotes[n]}")
234
+ return 0
235
+ # add
236
+ cfg.remotes[args.name] = args.path
237
+ config_mod.save(cfg)
238
+ print(f"已加 remote {args.name} = {args.path}")
239
+ return 0
240
+
241
+
242
+ def _print_stage(res: merge_mod.StageResult) -> None:
243
+ head = {"staged": "已保留", "already-staged": "已存在(未覆蓋)", "would-stage": "將保留",
244
+ "empty": "無內容可保留", "degraded": "不完整(某側讀不到,需重跑)",
245
+ "incomplete": "殘缺(需重跑)", "stale": "暫存已過時(衝突已變,需重跑)",
246
+ "error": "失敗"}.get(res.status, res.status)
247
+ # project_key/key 可含控制字元/surrogate(非 UTF-8 檔名)→ 過 _disp,免 print 在嚴格 stdout 編碼下崩潰(codex R1 Low)。
248
+ # key 用 _key_disp(fuzzy 的 `a\x00b` 顯示為「a ↔ b」,一般 key 無 NUL 不受影響)。dest 的根來自 XDG_CACHE_HOME
249
+ # (POSIX 可含非 UTF-8 bytes)→ 亦過 _disp,免暫存成功後印結果才崩 strict stdout(g3 Low)。
250
+ print(f" ● {merge_mod._disp(res.conflict.project_key)} / {merge_mod._key_disp(res.conflict.key)} "
251
+ f"[{head}] → {merge_mod._disp(str(res.dest))}")
252
+ for f in res.files:
253
+ print(f" + {f}")
254
+ if res.status == "staged":
255
+ print(f" + {merge_mod.META_FILE} / {merge_mod.PROMPT_FILE}")
256
+ for n in res.notes:
257
+ # notes 可含 raw 檔名(退化警告嵌 `missing` 檔名)→ 過 _disp,免 surrogate/非 UTF-8 檔名崩 strict stdout
258
+ # (R1 Low;build_prompt/format_conflicts/_conflict_meta 早已 _disp notes,_print_stage 原為唯一漏網)。
259
+ print(f" · {merge_mod._disp(n)}")
260
+
261
+
262
+ def _dup_target_pks(plan, project: str | None = None) -> frozenset[str]:
263
+ """被 ≥2 個 local 專案映到的 remote/hub 夾名(pk)。暫存夾名以 pk 為基底(`<merge>/<pk>/<key>`)→ 多對一時
264
+ 第二者撞夾被當 stale/already-staged,某專案兩版沒被獨立保留(對稱 `transfer` 的 skipped-dup-target,g1 Medium)。
265
+ `project` 給定時只算該 pk(g2 Medium:否則 scoped 指令會被無關專案的 dup 誤觸警告/非零)。"""
266
+ counts: dict[str, int] = {}
267
+ for pp in plan.projects:
268
+ if pp.local_dir and pp.hub_dir:
269
+ pk = Path(pp.hub_dir).name
270
+ if project is not None and pk != project:
271
+ continue
272
+ counts[pk] = counts.get(pk, 0) + 1
273
+ return frozenset(pk for pk, c in counts.items() if c > 1)
274
+
275
+
276
+ def _own_hub_forbidden(local_root, cfg, override_hub) -> list[Path]:
277
+ """暫存不可落入的**任一受同步樹**:local + **override --hub 與 config own_hub 兩者**(g2 High:兩者都是實體
278
+ 受同步 hub,不可用 `or` 互斥——override 只換本次操作對象、config own_hub 仍被例行 sync 同步)+ **所有** remote
279
+ (g1 High:否則暫存落進某 remote 被那一群同步=外洩)。落在任一之內 → `unsafe_staging_root` fail-closed 拒絕。"""
280
+ forbidden = [Path(local_root)]
281
+ for h in (override_hub, cfg.own_hub):
282
+ if h:
283
+ forbidden.append(Path(h))
284
+ forbidden += [Path(p) for p in cfg.remotes.values()]
285
+ return forbidden
286
+
287
+
288
+ def _emit_memory_conflicts(conflicts, unscannable, staging_forbidden, args, *,
289
+ dup_pks: frozenset[str] = frozenset()) -> int:
290
+ """memory-merge 兩條路(own-hub / 跨群 --from)共用的**報告 + 保留兩版 + 提示詞**尾段(避免漂移)。
291
+ `staging_forbidden`=暫存根不可落入的**任一受同步樹**(local + own hub + 所有 remote)。`dup_pks`=多對一撞夾的
292
+ remote/hub 夾名 → 其衝突全數跳過(fail-closed,不讓第二專案版本被覆蓋/丟)+非零。"""
293
+ if dup_pks:
294
+ print(f"⚠ 多個本機專案映到同一 remote/hub 夾({', '.join(sorted(dup_pks))})→ 暫存夾名會撞、"
295
+ "已跳過該夾**所有**衝突(避免某專案兩版被覆蓋);請一對一配對後重跑。")
296
+ conflicts = [c for c in conflicts if c.project_key not in dup_pks]
297
+ # memory 被跳過(memory/ 根 symlink 等)→ 不能把「沒掃到」誤當「無衝突」(gate2 F3):surface + 非零。
298
+ if unscannable:
299
+ print("⚠ 下列專案的 memory 未被掃描(無法判斷是否有衝突),請修為實體目錄後重跑:")
300
+ for u in unscannable:
301
+ print(f" - {u}")
302
+ if not conflicts:
303
+ if not unscannable and not dup_pks:
304
+ print("(未偵測到 memory 衝突)")
305
+ return 1 if (unscannable or dup_pks) else 0
306
+ print(merge_mod.LEAK_WARNING)
307
+ # 退化衝突(plan 後某側讀不到)/ memory 跳過 / 多對一撞夾 → 非零提醒重跑(gate F2/F3),不論模式。
308
+ rc = 1 if (unscannable or dup_pks or any(c.notes for c in conflicts)) else 0
309
+ if args.apply:
310
+ # 暫存根(XDG_CACHE_HOME)不可盲信:相對路徑 / 落在任一受同步樹內 → fail-closed 拒絕寫入(避免外洩,codex
311
+ # R1 High)。--from 時 forbidden 必含 **remote**,否則兩版可能落進 remote hub 被對方群當新 memory 擴散。
312
+ bad = merge_mod.unsafe_staging_root(merge_mod.merge_root(), staging_forbidden)
313
+ if bad:
314
+ print(f"\n⚠ 拒絕保留兩版(暫存根不安全):{bad}", file=sys.stderr)
315
+ rc = 1
316
+ else:
317
+ print("\n=== 保留兩版到本機暫存(memory/ 之外,絕不同步)===")
318
+ for c in conflicts:
319
+ res = merge_mod.stage_conflict(c, apply=True)
320
+ _print_stage(res)
321
+ if res.status in ("error", "incomplete", "degraded", "stale") or any("失敗" in n for n in res.notes):
322
+ rc = 1
323
+ if args.prompt_stdout:
324
+ print("\n=== 合併提示詞(stdout;貼進 Claude 前請先刪減敏感段)===")
325
+ for c in conflicts:
326
+ print(merge_mod.build_prompt(c))
327
+ print()
328
+ if not args.apply and not args.prompt_stdout:
329
+ print(merge_mod.format_conflicts(conflicts))
330
+ print(f"\n共 {len(conflicts)} 個 memory 衝突。加 --apply 保留兩版到本機暫存(含 PROMPT.md);"
331
+ "--prompt-stdout 印合併提示詞到 stdout。")
332
+ return rc
333
+
334
+
335
+ def _memory_merge_remote_identity(mappings: dict[str, str]):
336
+ """跨群 memory-merge 的 local→remote 夾配對 identity_fn(餵 build_plan):`--map`(local夾名=remote夾名)優先、
337
+ 否則 git 指紋(對稱 `transfer._resolve_pair`;工具尚未寫 `_project.json` sidecar → 無 --map 多半 needs-map,同
338
+ transfer)。逃逸由 build_plan 的 `_list_project_dirs` + `merge.conflicts_from_plan` 的 `_safe_project_dir` 雙守。"""
339
+ def resolve(local_dir: Path, remote_dirs: list[Path]) -> tuple[str, Path | None]:
340
+ tgt = mappings.get(local_dir.name)
341
+ if tgt is not None:
342
+ for rd in remote_dirs:
343
+ if rd.name == tgt:
344
+ return ("match", rd)
345
+ return ("needs-map", None) # 指定的 remote 夾當前不存在 → 不憑空配對
346
+ return scan._git_identity(local_dir, remote_dirs)
347
+ return resolve
348
+
349
+
350
+ def _cmd_memory_merge_remote(args) -> int:
351
+ """跨群 `memory-merge --from <remote>`:偵測**本機 memory ↔ remote hub memory** 的衝突(conflict-content /
352
+ remote-tombstone delete-vs-update;沿用 A3 尊重 remote tombstone、不跨群復活),保留兩版到本機暫存、產提示詞。
353
+ **stateless**(無 per-remote 基線,對稱 transfer):故單邊新檔=blocked-no-baseline(非衝突、不 stage)、
354
+ 跨檔改名身分(cross-file-identity)此版**不偵測**(需基線語意;留 P2,同 transfer 的 stateless 殘留)。"""
355
+ try:
356
+ cfg = config_mod.load()
357
+ except config_mod.ConfigError as e:
358
+ print(f"config.toml 損壞,保守中止:{e}", file=sys.stderr)
359
+ return 1
360
+ name = args.from_remote
361
+ remote_path = cfg.remotes.get(name)
362
+ if not remote_path:
363
+ have = ", ".join(sorted(cfg.remotes)) or "(無)"
364
+ print(f"未知 remote '{name}';先 `remote add {name} <path>`。現有:{have}", file=sys.stderr)
365
+ return 1
366
+ mappings, merr = _parse_maps(args.map)
367
+ if merr:
368
+ print(merr, file=sys.stderr)
369
+ return 1
370
+ local_root = args.local_root or str(default_local_root())
371
+ # stateless(state=None,對稱 transfer):衝突偵測不需基線(conflict-content=兩側皆在且異;delete-vs-update=
372
+ # remote tombstone gate〕;memory_only 跳過 session;identity_fn 走 --map/git。halt(remote 未掛載)→ surface。
373
+ plan = scan.build_plan(local_root, remote_path, None,
374
+ identity_fn=_memory_merge_remote_identity(mappings), memory_only=True)
375
+ if plan.halt:
376
+ print(scan.format_plan(plan))
377
+ return 2
378
+ conflicts = merge_mod.conflicts_from_plan(plan, project=args.project)
379
+ unscannable = merge_mod.unscannable_memory_projects(plan, project=args.project)
380
+ print(f"跨群 memory-merge(--from {name}):偵測本機與 remote({remote_path})的 memory 衝突")
381
+ # 誠實範圍聲明(codex R1 Medium):stateless 跨群偵測「同檔名內容衝突」與「remote 刪除 vs 本機更新」,**不含**
382
+ # 跨檔改名同一事實(cross-file-identity,需基線)→ 故「未偵測到衝突」不代表跨檔改名也沒有;別讓 partial 看似完整。
383
+ print("(範圍:同檔名內容衝突 + remote 刪除衝突;不含跨檔改名同一事實〔留 P2〕,見 docs/memory-merge-from.md)")
384
+ # 未配對的本機專案(needs-map/blocked-*)=有 local 夾但無對應 remote 夾、**未比對** → **一律**印(不只在無衝突時,
385
+ # codex R1 Medium:否則有衝突時 partial-scan 被包成完整掃描);但受 `--project` 範圍限制(g1 Low:scoped 掃描不該
386
+ # 報無關專案未配對)。提示補 --map。
387
+ unpaired = [pp.local_dir for pp in plan.projects
388
+ if pp.local_dir and not pp.hub_dir
389
+ and (not args.project or Path(pp.local_dir).name == args.project)]
390
+ if unpaired:
391
+ print(f"({len(unpaired)} 個本機專案未對應到 remote 夾、未比對;請用 --map 本機夾名=remote夾名 指定後重跑)")
392
+ staging_forbidden = _own_hub_forbidden(local_root, cfg, args.hub) # local + args.hub + cfg.own_hub + 所有 remote
393
+ return _emit_memory_conflicts(conflicts, unscannable, staging_forbidden, args,
394
+ dup_pks=_dup_target_pks(plan, args.project))
395
+
396
+
397
+ # 顯示一律過 merge._disp:memory 檔名/**專案夾名**在 POSIX 可含非 UTF-8 bytes(surrogateescape)或控制字元 →
398
+ # 直接 print 會 UnicodeEncodeError 崩潰(strict UTF-8 stdout)或破壞單行;比照 merge.format_conflicts 中和。
399
+
400
+
401
+ def _print_fuzzy_unscannable(unscannable: list[str]) -> None:
402
+ """印 memory 未掃描警告(含內嵌 raw pk——fuzzy-g1 Low:此警告行也須過 _disp)。Block A 列出與 Block B 放行共用。"""
403
+ if not unscannable:
404
+ return
405
+ print("⚠ 下列專案的 memory 未被掃描(無法判斷是否有近似候選),請修為實體目錄後重跑:")
406
+ for u in sorted(set(unscannable)):
407
+ print(f" - {merge_mod._disp(u)}")
408
+
409
+
410
+ def _print_fuzzy_candidate_lines(candidates: list) -> None:
411
+ """逐候選印 advisory 摘要行(pk 標頭 + 相似度 + 共享詞元 + 兩側 name;全過 _disp 防 surrogate 崩)。列出與
412
+ --stage 前置檢視共用同一份顯示邏輯(surrogate 安全性單一真相源)。"""
413
+ d = merge_mod._disp
414
+ cur = None
415
+ for c in candidates:
416
+ if c.project_key != cur:
417
+ cur = c.project_key
418
+ print(f"\n● {d(c.project_key)}")
419
+ print(f" ~ {d(c.a)} ↔ {d(c.b)} 相似度 {c.score:.2f}(name {c.name_sim:.2f} / desc {c.desc_sim:.2f})")
420
+ if c.shared_name_tokens:
421
+ print(f" 共享 name 詞元:{d(', '.join(c.shared_name_tokens))}")
422
+ print(f" A: name={d(c.name_a) or '(無/不可判)'}")
423
+ print(f" B: name={d(c.name_b) or '(無/不可判)'}")
424
+
425
+
426
+ def _emit_fuzzy(candidates: list, unscannable: list[str], threshold: float) -> int:
427
+ """fuzzy 唯讀列出(Block A)。**只印、不寫**。unscannable(memory 沒掃到)→ 非零(不把「沒掃到」誤當「無候選」,
428
+ 比照 `_emit_memory_conflicts`);找到候選=資訊性、非失敗 → 0。放行(保留兩版)走 --stage/--interactive。"""
429
+ _print_fuzzy_unscannable(unscannable)
430
+ if not candidates:
431
+ if not unscannable:
432
+ print(f"(未偵測到 memory 模糊近似候選;閾值 {threshold})")
433
+ return 1 if unscannable else 0
434
+ print("=== memory 模糊近似候選(advisory;只提示、絕不自動合併、不寫任何檔)===")
435
+ print(f"(純 name+description 字面比對、閾值 {threshold};這只是「疑似同一事實」的提示,請自行檢視是否真同一件事。)")
436
+ _print_fuzzy_candidate_lines(candidates)
437
+ print(f"\n共 {len(candidates)} 對疑似重複(advisory)。fuzzy 永不自動合併——請自行檢視。要把兩版保留到本機暫存"
438
+ "供合併,加 --stage(全部)或 --interactive(逐對確認)。")
439
+ return 1 if unscannable else 0
440
+
441
+
442
+ def _fuzzy_reason(cand) -> str:
443
+ """fuzzy 衝突的 reason 字串(顯示用、進 build_prompt/format 時再過 _disp;不進 fingerprint)。"""
444
+ shared = "、".join(cand.shared_name_tokens) if cand.shared_name_tokens else "(無共享 name 詞元)"
445
+ return (f"模糊近似候選(相似度 {cand.score:.2f};name {cand.name_sim:.2f}/desc {cand.desc_sim:.2f};"
446
+ f"共享 name 詞元:{shared})")
447
+
448
+
449
+ def _fuzzy_stdin_decider(cand) -> bool:
450
+ """CLI 互動 decider(比照 `_stdin_decider` 樣式):對一對模糊候選問「當同一則、保留兩版?」。**預設 N**(保守——
451
+ 不放行就不寫,守 cardinal);EOF(無互動輸入)→ N。可由測試 monkeypatch `builtins.input`。"""
452
+ d = merge_mod._disp
453
+ print(f"\n 疑似同一事實:{d(cand.a)} ↔ {d(cand.b)} 相似度 {cand.score:.2f}"
454
+ f"(name {cand.name_sim:.2f} / desc {cand.desc_sim:.2f})")
455
+ if cand.shared_name_tokens:
456
+ print(f" 共享 name 詞元:{d(', '.join(cand.shared_name_tokens))}")
457
+ print(f" A: name={d(cand.name_a) or '(無/不可判)'}")
458
+ print(f" B: name={d(cand.name_b) or '(無/不可判)'}")
459
+ try:
460
+ ans = input(" 當成同一則、保留兩版供合併?[y/N]: ").strip().lower()
461
+ except EOFError:
462
+ return False
463
+ return ans in ("y", "yes")
464
+
465
+
466
+ def _run_fuzzy_stage(args, candidates: list, unscannable: list[str], threshold: float,
467
+ score_src: dict, staging_forbidden: list) -> int:
468
+ """Block B:把使用者**放行**的模糊候選導進 leak-safe 保留兩版(`--stage`=全部 / `--interactive`=逐對確認)。
469
+
470
+ cardinal=**放行才寫**(分數不裁定)+**不外洩**(暫存根過 `unsafe_staging_root`、同一般衝突路徑)。每檔只從其
471
+ **計分來源側**(`score_src[pk][檔名鍵]`=單一 `(side, mdir)`)讀取,**不**回退/probe 別側同名檔(g2 High:杜絕靜默
472
+ 替換;亦天然免除多對一夾歧義)。全程只讀正式 memory、只寫 memory/ 外的暫存(A3)。"""
473
+ interactive = args.interactive
474
+ rc = 1 if unscannable else 0
475
+ _print_fuzzy_unscannable(unscannable)
476
+ if not candidates:
477
+ if not unscannable:
478
+ print(f"(未偵測到 memory 模糊近似候選;閾值 {threshold})")
479
+ return rc
480
+ if interactive:
481
+ print("=== 逐對確認模糊近似候選(放行才保留兩版;預設 N)===")
482
+ else: # --stage:先完整列出(scores+names)供檢視,再全部保留
483
+ print("=== memory 模糊近似候選(將全部保留兩版供合併)===")
484
+ print(f"(純 name+description 字面比對、閾值 {threshold};保留只是把兩版存到本機暫存供你合併——請自行檢視是否真同一件事。)")
485
+ _print_fuzzy_candidate_lines(candidates)
486
+ approved: list = []
487
+ for cand in candidates:
488
+ if interactive and not _fuzzy_stdin_decider(cand):
489
+ continue
490
+ # 只從各檔的**計分來源側**讀(單一 (side, mdir));找不到來源 → 空 → fuzzy_conflict 判缺 → degraded。
491
+ src = score_src.get(cand.project_key, {})
492
+ a_src = src.get(scan._name_key(cand.a))
493
+ b_src = src.get(scan._name_key(cand.b))
494
+ approved.append(merge_mod.fuzzy_conflict(
495
+ cand.project_key, cand.a, [a_src] if a_src else [], cand.b, [b_src] if b_src else [],
496
+ reason=_fuzzy_reason(cand)))
497
+ if not approved:
498
+ print("\n(未放行任何候選;未保留任何內容。)")
499
+ return rc
500
+ if any(c.notes for c in approved): # 放行後某檔讀不到/專案夾逃逸 → 退化 → 非零(比照 _emit_memory_conflicts;
501
+ rc = 1 # R1 Medium:否則「兩檔皆消失」→ stage_conflict 回 empty、rc 仍 0,誤報成功)
502
+ # leak-safe:暫存根不可落在任一受同步樹(local/hub/所有 remote)→ 否則兩版+PROMPT.md 落同步區外洩(同一般衝突)。
503
+ bad = merge_mod.unsafe_staging_root(merge_mod.merge_root(), staging_forbidden)
504
+ if bad: # bad 內嵌暫存根/受同步樹路徑(POSIX 可含 surrogate)→ 過 _disp,免拒絕訊息崩 strict stderr(g3 Low 同類)。
505
+ print(f"\n⚠ 拒絕保留兩版(暫存根不安全):{merge_mod._disp(bad)}", file=sys.stderr)
506
+ return 1
507
+ print("\n" + merge_mod.LEAK_WARNING)
508
+ print("\n=== 保留兩版到本機暫存(memory/ 之外,絕不同步)===")
509
+ for c in approved:
510
+ res = merge_mod.stage_conflict(c, apply=True)
511
+ _print_stage(res)
512
+ # empty(兩檔皆讀不到)亦為失敗(R1 Medium);degraded/incomplete/stale/error 與寫入失敗同。
513
+ if res.status in ("error", "incomplete", "degraded", "stale", "empty") \
514
+ or any("失敗" in n for n in res.notes):
515
+ rc = 1
516
+ if args.prompt_stdout:
517
+ print("\n=== 合併提示詞(stdout;貼進 Claude 前請先刪減敏感段)===")
518
+ for c in approved:
519
+ print(merge_mod.build_prompt(c))
520
+ print()
521
+ return rc
522
+
523
+
524
+ def _cmd_memory_merge_fuzzy(args) -> int:
525
+ """memory-merge --fuzzy:列「同事實、不同檔名」的模糊近似候選(Block A,唯讀 advisory);使用者以 `--stage`
526
+ (全部)/`--interactive`(逐對確認)**放行**後才把兩版保留到 memory/ 外的暫存(Block B)。
527
+
528
+ **cardinal**:fuzzy 分數**永不裁定**——列出永不寫檔;保留兩版**只在使用者放行後**發生,且**只讀正式 memory、
529
+ 只寫 memory/ 外暫存、絕不碰 classify/apply/sync**(誤判在此至多多存一組暫存,零資料危害)。掃**兩側 memory 的
530
+ 聯集**(依 `_name_key` 去重;一側缺/不可掃仍就另一側算,近似重複可能只在單側),逐專案算候選。"""
531
+ if getattr(args, "from_remote", None):
532
+ print("跨群 fuzzy(--fuzzy --from)尚未實作;目前 --fuzzy 只支援 own-hub。", file=sys.stderr)
533
+ return 1
534
+ stage = getattr(args, "stage", False)
535
+ interactive = getattr(args, "interactive", False)
536
+ if getattr(args, "apply", False): # fuzzy 的放行動詞是 --stage/--interactive,不是 --apply(避免誤用)。
537
+ print("--fuzzy 的候選放行請用 --stage(全部)或 --interactive(逐對),不是 --apply。", file=sys.stderr)
538
+ return 1
539
+ if getattr(args, "prompt_stdout", False) and not (stage or interactive):
540
+ print("--fuzzy 搭配 --prompt-stdout 需再加 --stage 或 --interactive(決定為哪些候選產生提示詞)。",
541
+ file=sys.stderr)
542
+ return 1
543
+ ctx, err = _resolve_context(args)
544
+ if err:
545
+ print(err, file=sys.stderr)
546
+ return 1
547
+ assert ctx is not None
548
+ threshold = args.fuzzy_threshold
549
+ if not (0.0 <= threshold <= 1.0): # 也擋 nan(任何比較皆 False → 落此)/inf/負/>1(codex r1 Low:nan 否則全印)
550
+ print("--fuzzy-threshold 須為 0~1 之間的數(含邊界)", file=sys.stderr)
551
+ return 1
552
+ # memory_only:fuzzy 只看 memory、不需 session 分類(省最重的一段,比照 nudge/--from)。halt(掛錯碟等)→ surface。
553
+ plan = scan.build_plan(ctx.local_root, ctx.hub, ctx.state, memory_only=True)
554
+ if plan.halt:
555
+ print(scan.format_plan(plan))
556
+ return 2
557
+ # pk 桶完整性(e2e-r1 F1 + g1 + g2):不同專案的 memory 絕不可混進同一暫存命名空間。三類 fail-closed 跳過:
558
+ # (a) ≥2 本機專案綁同一 hub 夾;(b) 某 local-only 專案名恰等於另一專案的 hub pk(local A→hub P 與 local-only P
559
+ # 皆落 pk="P",g1)——(a)(b) 皆「同一 raw pk 桶收到 >1 相異 local 或 hub 側」;(c) 兩個**相異 raw pk**(如 local "P"
560
+ # + hub "p")大小寫/正規化折疊後相同 → 各自獨立 by_pk 桶、但暫存夾 <merge>/<pk>/… 在**大小寫/正規化不敏感的快取
561
+ # FS**(Windows NTFS 預設)上撞成同一實體夾、且 `_conflict_fingerprint` 已納 pk 但夾仍撞 → 混淆(g2)。用 fuzzy 自己
562
+ # 的 pk 推導(hub 名優先、退 local 名)逐 pp 累計各 raw pk 的相異 local/hub 側 + 依 `scan._name_key` 分組折疊。
563
+ _pk_locals: dict[str, set] = {}
564
+ _pk_hubs: dict[str, set] = {}
565
+ for _pp in plan.projects:
566
+ _pk = Path(_pp.hub_dir).name if _pp.hub_dir else (Path(_pp.local_dir).name if _pp.local_dir else None)
567
+ if _pk is None:
568
+ continue
569
+ if _pp.local_dir:
570
+ _pk_locals.setdefault(_pk, set()).add(str(Path(_pp.local_dir)))
571
+ if _pp.hub_dir:
572
+ _pk_hubs.setdefault(_pk, set()).add(str(Path(_pp.hub_dir)))
573
+ _pks = set(_pk_locals) | set(_pk_hubs)
574
+ _by_name_key: dict[str, set] = {} # (c):折疊鍵 → 相異 raw pk 集;>1 → 折疊撞(暫存夾在不敏感 FS 上會撞)
575
+ for pk in _pks:
576
+ _by_name_key.setdefault(scan._name_key(pk), set()).add(pk)
577
+ dup_pks = frozenset(pk for pks in _by_name_key.values() if len(pks) > 1 for pk in pks) | frozenset(
578
+ pk for pk in _pks if len(_pk_locals.get(pk, ())) > 1 or len(_pk_hubs.get(pk, ())) > 1)
579
+ # **依專案夾名(pk)聚合兩側 memory**(codex r1 Medium):同名的 local/hub 專案即使未經 identity 配對,其 memory 仍
580
+ # 歸同一 pk 桶 → 跨側近似候選抓得到、且每 pk 只列一次(不重複標頭)。桶內依 `_name_key` 去重(同一檔別名拼寫)。
581
+ # 放行時(Block B)需知每檔的**計分來源**以精確重讀:`score_src[pk][檔名鍵] = (side, memory夾)`=**計分那一則
582
+ # 所在的單一確切側/夾**(首見側)。放行後 stage **只**從這個確切來源讀 `cand.a`/`cand.b`——**不**回退別側、**不** probe
583
+ # 別側的同名/別名檔(g2 High:綁「檔名鍵的所有側」仍會在計分側消失時讀到別側無關同名檔=靜默替換;綁單一計分側
584
+ # 才杜絕)。同時天然免除「多本機夾→同 pk」歧義(讀的是確切來源夾、非「某一側」,各檔各讀其來源)。
585
+ by_pk: dict[str, dict[str, fuzzy_mod.FuzzyEntry]] = {}
586
+ score_src: dict[str, dict[str, tuple[str, Path]]] = {}
587
+ unscannable: list[str] = []
588
+ for pp in plan.projects:
589
+ pk = Path(pp.hub_dir).name if pp.hub_dir else (Path(pp.local_dir).name if pp.local_dir else None)
590
+ if pk is None:
591
+ continue
592
+ if args.project is not None and pk != args.project:
593
+ continue
594
+ if pk in dup_pks: # 不同專案會混進同一暫存命名空間(同一 pk 桶,或大小寫/正規化折疊後暫存夾相撞)→ 跳過整個
595
+ # pk(fail-closed;避免不同專案 memory 混淆/靜默取首見側/暫存撞名,e2e-r1 F1 + g1 + g2)
596
+ unscannable.append(f"{pk}(此 pk 會與其他專案的 memory 混〔同一 pk 桶,或大小寫/正規化折疊後暫存夾相撞〕→ "
597
+ "已跳過;請確認專案夾名/配對後重跑)")
598
+ continue
599
+ entries = by_pk.setdefault(pk, {}) # _name_key(檔名) → entry(計分用;首見側)
600
+ for d, side in ((pp.local_dir, "local"), (pp.hub_dir, "hub")):
601
+ if not d:
602
+ continue
603
+ dd = Path(d)
604
+ # 專案夾逃逸(symlink/junction 逃出信任根)→ 不從界外讀 memory(比照 merge.unscannable_memory_projects)。
605
+ if not scan._safe_project_dir(dd.parent, dd):
606
+ unscannable.append(f"{pk}({side}:專案夾為 symlink/逃逸信任根)")
607
+ continue
608
+ mdir = memory_mod.memory_dir(d)
609
+ try:
610
+ files = memory_mod.list_memory_files(mdir)
611
+ except memory_mod.UnsafeMemoryDir:
612
+ unscannable.append(f"{pk}({side}:memory/ 根為 symlink)")
613
+ continue
614
+ except OSError as e:
615
+ unscannable.append(f"{pk}({side}:memory 夾讀取失敗 {e.__class__.__name__})")
616
+ continue
617
+ for fname in files:
618
+ key = scan._name_key(fname)
619
+ if key in entries:
620
+ continue # 計分+來源都只留首見側(另一側/別名拼寫同鍵視為同檔、不覆蓋來源綁定)
621
+ # **父夾+最終檔 no-follow 讀**(codex r1 Medium):list_memory_files 列舉時已排除 symlink leaf,但列舉→讀取
622
+ # 間檔可被抽換成 symlink → 跟隨讀到界外檔;比照 merge._read_nofollow(單一真相源)。讀不到/被換 → 略過。
623
+ data = merge_mod._read_nofollow(mdir, fname)
624
+ if data is None:
625
+ continue
626
+ doc = memory_mod.load_memory_bytes(data)
627
+ desc = None
628
+ if doc.fm_ok and doc.frontmatter:
629
+ dv = doc.frontmatter.get("description")
630
+ desc = dv if isinstance(dv, str) else None
631
+ entries[key] = fuzzy_mod.FuzzyEntry(fname, doc.name, desc)
632
+ score_src.setdefault(pk, {})[key] = (side, mdir) # 綁定計分來源側(放行後只讀這裡,不猜別側)
633
+ candidates: list = []
634
+ for pk in sorted(by_pk):
635
+ candidates.extend(fuzzy_mod.find_candidates(pk, list(by_pk[pk].values()), threshold=threshold))
636
+ if not stage and not interactive: # Block A:唯讀列出
637
+ return _emit_fuzzy(candidates, unscannable, threshold)
638
+ # Block B:使用者放行 → leak-safe 保留兩版。暫存不可落任一受同步樹(local + hub 兩者 + 所有 remote)。
639
+ staging_forbidden = _own_hub_forbidden(ctx.local_root, ctx.config, args.hub)
640
+ return _run_fuzzy_stage(args, candidates, unscannable, threshold, score_src, staging_forbidden)
641
+
642
+
643
+ def _cmd_memory_merge(args) -> int:
644
+ """memory-merge(DESIGN §7.3 / §9):偵測 memory 衝突 → 保留兩版到本機暫存(memory/ 之外、不同步)+ 報告
645
+ + 產合併提示詞(明文外洩警告)。**獨立指令、不併進 sync**;真正 AI 合併留 P2。預設 dry-run。
646
+ `--from <remote>` =跨群版(本機 ↔ remote hub,見 `_cmd_memory_merge_remote`);不給則 own-hub。
647
+ `--fuzzy` =唯讀模糊近似候選列出(Block A;只提示、不寫檔,見 `_cmd_memory_merge_fuzzy`)。"""
648
+ if getattr(args, "fuzzy", False):
649
+ return _cmd_memory_merge_fuzzy(args)
650
+ if getattr(args, "stage", False) or getattr(args, "interactive", False):
651
+ print("--stage / --interactive 僅用於 --fuzzy 的候選放行;一般 memory 衝突請用 --apply(保留兩版)或 "
652
+ "--prompt-stdout(印提示詞)。", file=sys.stderr)
653
+ return 1
654
+ if getattr(args, "from_remote", None):
655
+ return _cmd_memory_merge_remote(args)
656
+ ctx, err = _resolve_context(args)
657
+ if err:
658
+ print(err, file=sys.stderr)
659
+ return 1
660
+ assert ctx is not None
661
+ # 先 build_plan 並 surface halt(掛錯碟/指紋變等)——否則 find_conflicts 在 halt 時靜默回「無衝突」誤導使用者。
662
+ plan = scan.build_plan(ctx.local_root, ctx.hub, ctx.state)
663
+ if plan.halt:
664
+ print(scan.format_plan(plan))
665
+ return 2
666
+ conflicts = merge_mod.conflicts_from_plan(plan, project=args.project)
667
+ unscannable = merge_mod.unscannable_memory_projects(plan, project=args.project)
668
+ # local + args.hub + cfg.own_hub + 所有 remote 都不可落入(g1/g2 High;ctx.hub=args.hub or own_hub 已被涵蓋)。
669
+ staging_forbidden = _own_hub_forbidden(ctx.local_root, ctx.config, args.hub)
670
+ return _emit_memory_conflicts(conflicts, unscannable, staging_forbidden, args,
671
+ dup_pks=_dup_target_pks(plan, args.project))
672
+
673
+
674
+ def _doctor_show_acked(hub, project) -> int:
675
+ """列出已 acknowledged 的 damaged/collision 項(A15)。純讀、不寫。"""
676
+ dirs = doctor_mod.hub_project_dirs(hub)
677
+ if project:
678
+ dirs = [d for d in dirs if d.name == project]
679
+ shown = False
680
+ for hd in dirs:
681
+ led = acks_mod.load_ledger(hd)
682
+ if not led.ok:
683
+ print(f"專案 {hd.name}:acks.json 損壞(已忽略、全部照常回報)")
684
+ shown = True
685
+ continue
686
+ if not led.by_key:
687
+ continue
688
+ print(f"專案 {hd.name}:")
689
+ for _key, rec in sorted(led.by_key.items()): # key=(kind,identity,fingerprint) 三元組(g2);顯示用 rec 欄位
690
+ print(f" · [{rec.get('kind')}] {rec.get('label') or rec.get('identity')}"
691
+ f"({rec.get('acked_at', '?')} @ {rec.get('acked_by', '?')})")
692
+ shown = True
693
+ if not shown:
694
+ print("目前沒有任何 acknowledged 項。")
695
+ return 0
696
+
697
+
698
+ def _group_by_hub_dir(items):
699
+ """保序把 AckItem 依 hub_dir 分組(回 [(hub_dir, [items])])。"""
700
+ order: list[str] = []
701
+ by_dir: dict[str, list] = {}
702
+ for it in items:
703
+ if it.hub_dir not in by_dir:
704
+ by_dir[it.hub_dir] = []
705
+ order.append(it.hub_dir)
706
+ by_dir[it.hub_dir].append(it)
707
+ return [(hd, by_dir[hd]) for hd in order]
708
+
709
+
710
+ def _doctor_ack(local_root, hub, state_path, project, *, apply) -> int:
711
+ """把目前所有 damaged/collision blocked 項標記 acknowledged(A15)。預覽預設、--yes 落地。
712
+ 列舉走 read-only `build_plan`(單一真相源,指紋與 sync 一致);state 損壞則請先 --rebuild-state。"""
713
+ try:
714
+ st = state_mod.load_or_none(state_path)
715
+ except state_mod.StateCorruptError as e:
716
+ print(f"state 損壞,無法列舉可 ack 項(請先 doctor --rebuild-state):{e}", file=sys.stderr)
717
+ return 1
718
+ plan = scan.build_plan(local_root, hub, st)
719
+ if plan.halt:
720
+ print(scan.format_plan(plan))
721
+ print("\n偵測到 halt 級異常,未進行 ack。", file=sys.stderr)
722
+ return 2
723
+ items = acks_mod.ackable_from_plan(plan)
724
+ if project:
725
+ items = [it for it in items if it.project == project]
726
+ # 濾掉不可綁定內容者(fp=None:讀不到檔)——無從綁定內容變動 → 不可 ack(fail-closed,g6);提示使用者。
727
+ unbindable = [it for it in items if it.fingerprint is None]
728
+ items = [it for it in items if it.fingerprint is not None]
729
+ if unbindable:
730
+ print(f"({len(unbindable)} 項因讀不到內容無法 ack、將持續回報:"
731
+ f"{', '.join(sorted(f'{it.project}/{it.label}' for it in unbindable))})")
732
+ if not items:
733
+ suffix = f"(--project {project})" if project else ""
734
+ print(f"目前沒有可 acknowledge 的 damaged/collision 項。{suffix}")
735
+ return 0
736
+ print("將 acknowledge 以下 damaged/collision 項(審閱後 sync/doctor 不再重報;內容/撞名集變動會自動重新提示):")
737
+ to_ack = {} # hub_dir -> [AckItem](僅需新增者)
738
+ for hub_dir, group in _group_by_hub_dir(items):
739
+ led = acks_mod.load_ledger(hub_dir)
740
+ pk = group[0].project
741
+ for it in group:
742
+ new = not acks_mod.is_acked(led, it.kind, it.identity, it.fingerprint)
743
+ print(f" · [{pk}] {it.kind} {it.label} — {'新增' if new else '已 ack(略過)'}")
744
+ if new:
745
+ to_ack.setdefault(hub_dir, []).append(it)
746
+ if not to_ack:
747
+ print("(皆已 acknowledged,無新增。)")
748
+ return 0
749
+ if not apply:
750
+ print("\n(預覽)加 --yes 寫入 ack 帳本。")
751
+ return 0
752
+ n, err = 0, False
753
+ for hub_dir, group in to_ack.items():
754
+ try:
755
+ res = acks_mod.update_ledger(hub_dir, add=group)
756
+ n += len(res.added)
757
+ except (acks_mod.UnsafeAcksDir, atomicio_mod.LockError, atomicio_mod.AtomicWriteError, OSError) as e:
758
+ print(f"⚠ 寫入 ack 失敗({Path(hub_dir).name}):{e}", file=sys.stderr)
759
+ err = True
760
+ print(f"\n已 acknowledge {n} 項。")
761
+ return 1 if err else 0
762
+
763
+
764
+ def _doctor_unack(hub, project, *, apply) -> int:
765
+ """取消 acknowledgement(A15)。ledger 驅動(含已無對應現況項的孤兒 ack 也能清)。預覽預設、--yes 落地。"""
766
+ dirs = doctor_mod.hub_project_dirs(hub)
767
+ if project:
768
+ dirs = [d for d in dirs if d.name == project]
769
+ targets = [] # (hub_dir, key, label);key=(kind,identity,fingerprint) 三元組(g2)
770
+ for hd in dirs:
771
+ led = acks_mod.load_ledger(hd)
772
+ for key, rec in sorted(led.by_key.items()):
773
+ targets.append((hd, key, rec.get("label") or rec.get("identity") or key[1]))
774
+ if not targets:
775
+ print("目前沒有任何 acknowledged 項可取消。")
776
+ return 0
777
+ print("將取消以下 acknowledgement(之後 sync/doctor 會重新回報):")
778
+ for hd, key, label in targets:
779
+ print(f" · [{hd.name}] {key[0]} {label}")
780
+ if not apply:
781
+ print("\n(預覽)加 --yes 取消上列 ack。")
782
+ return 0
783
+ by_dir = {}
784
+ for hd, key, _ in targets:
785
+ by_dir.setdefault(hd, []).append(key)
786
+ n, err = 0, False
787
+ for hd, keys in by_dir.items():
788
+ try:
789
+ res = acks_mod.update_ledger(hd, remove=keys)
790
+ n += len(res.removed)
791
+ except (acks_mod.UnsafeAcksDir, atomicio_mod.LockError, atomicio_mod.AtomicWriteError, OSError) as e:
792
+ print(f"⚠ 取消 ack 失敗({hd.name}):{e}", file=sys.stderr)
793
+ err = True
794
+ print(f"\n已取消 {n} 項 ack。")
795
+ return 1 if err else 0
796
+
797
+
798
+ def _cmd_doctor(args) -> int:
799
+ """維護工具。**不走 _resolve_context**(它對壞 state 會中止,而 --rebuild-state 正是要救壞 state)。"""
800
+ try:
801
+ cfg = config_mod.load()
802
+ except config_mod.ConfigError as e:
803
+ print(f"config.toml 損壞,保守中止:{e}", file=sys.stderr)
804
+ return 1
805
+ hub = args.hub or cfg.own_hub
806
+ if not hub:
807
+ print("尚未設定 own_hub:請先 `config set own-hub <path>` 或用 --hub。", file=sys.stderr)
808
+ return 1
809
+ local_root = args.local_root or str(default_local_root())
810
+ state_path = args.state or str(state_mod.default_state_path())
811
+
812
+ if args.break_lock:
813
+ # 只遞迴掃 hub + **明確的** state 鎖檔(不遞迴掃 state 父夾,免刪到無關 *.lock,codex r-doctor-3)。
814
+ state_lock = Path(str(state_path) + doctor_mod._LOCK_SUFFIX)
815
+ rep = doctor_mod.break_locks([Path(hub)], [state_lock], apply=args.yes)
816
+ print("\n".join(rep.lines))
817
+ print("\n※ break-lock 為單一操作者復原指令:請勿並行執行、且勿於 sync 進行中執行"
818
+ "(並行 break-lock 可能在 check→unlink 窗誤刪剛重取的活鎖=雙 writer;見 doctor.break_locks 說明)。")
819
+ if not args.yes and rep.kept:
820
+ print("(預覽)加 --yes 移除上列同機已死的 stale 鎖(跨機/存活者不動)。")
821
+ return 1 if rep.errors else 0
822
+
823
+ if args.rebuild_state:
824
+ mappings, merr = _parse_maps(args.map)
825
+ if merr:
826
+ print(merr, file=sys.stderr)
827
+ return 1
828
+ res = doctor_mod.rebuild_state(local_root, hub, mappings=mappings)
829
+ print("rebuild-state 預覽(hub 側無條件、local 側僅 --map;永不碰 tombstone):")
830
+ print("\n".join(res.lines) if res.lines else " (無可重建)")
831
+ if res.fatal: # hub 不存在/非目錄 → 不寫(否則覆成空 state,codex r-doctor-1)
832
+ return 1
833
+ if not args.yes:
834
+ print("\n(預覽)加 --yes 落地(覆寫 state.json)。")
835
+ return 0
836
+ try:
837
+ path = doctor_mod.write_rebuilt_state(res, state_path)
838
+ except (atomicio_mod.LockError, atomicio_mod.AtomicWriteError, OSError) as e:
839
+ # AtomicWriteError/VerifyError 非 OSError 子類(readback 驗證失敗會走這),須一併捕捉
840
+ # → 誠實非零退出,不外拋 traceback(codex r-doctor-5)。
841
+ print(f"\n寫入失敗:{e}", file=sys.stderr)
842
+ return 1
843
+ print(f"\n已重建 state:{path}")
844
+ return 0
845
+
846
+ if args.show_acked:
847
+ return _doctor_show_acked(hub, args.project)
848
+ if args.unack_all:
849
+ return _doctor_unack(hub, args.project, apply=args.yes)
850
+ if args.ack_all:
851
+ return _doctor_ack(local_root, hub, state_path, args.project, apply=args.yes)
852
+
853
+ rep = doctor_mod.diagnose(local_root, hub, state_path, cfg)
854
+ print(rep.text())
855
+ print(f"\n問題數:{rep.problems}")
856
+ return 1 if rep.problems else 0
857
+
858
+
859
+ # nudge 只提示**某指令實際會處理**的 memory 分歧,避免反覆吵不可動作項(DESIGN §7.5「有分歧」的可動作解讀)。
860
+ # 兩桶各綁其處理指令的**單一真相源動作集**——若那些集合日後變動,nudge 自動跟隨、不漂移:
861
+ # · 更新 = `sync --apply` 會自動寫入者 = `apply.MEM_AUTO_ACTIONS` 去掉 identical(identical=無分歧)。
862
+ # · 衝突 = `memory-merge` 會處理者 = `merge.CONFLICT_ACTIONS`。
863
+ # suppressed-deleted(已定案刪除、sync 不再動)/ blocked-*(工具無法自動解、靜音出口是 A15 ack)不在任一桶 → 不吵。
864
+ _NUDGE_UPDATE_ACTIONS = apply_mod.MEM_AUTO_ACTIONS - {"identical"}
865
+
866
+
867
+ def _nudge_summary(plan: scan.SyncPlan) -> str | None:
868
+ """把計畫濃縮成一行提示(無可動作分歧→None)。只看 memory:更新(sync 自動同步)與衝突(交 memory-merge)。
869
+
870
+ **只算 hub+local 兩側皆綁定的專案**(`pp.local_dir and pp.hub_dir`,g4 Medium):這正是兩個處理指令唯一會
871
+ 實際動到 memory 的專案型態——`memory-merge`(`merge.conflicts_from_plan`)明文只碰兩側皆綁者;`sync` 的 memory
872
+ auto-apply 也需 mapping+coverage,實測**未配對專案的每個 memory 動作都是 `blocked-unmapped`/`blocked-*`**(見
873
+ `tests/test_nudge` 的 hub-only 探測),本就不在任一桶。故加此閘不改變任何可達狀態的計數,卻能防未來 classify
874
+ 若把某 auto/conflict 洩漏到未配對專案時 nudge 去吵一個沒指令能處理的項(hub-only `conflict-delete-vs-update` 即
875
+ 如此:build_plan 會產它、但 memory-merge 不碰 → 不可動作 → 不吵)。"""
876
+ updates = conflicts = 0
877
+ for pp in plan.projects:
878
+ if not (pp.local_dir and pp.hub_dir): # 未配對(hub-only/local-only/needs-map…)→ 無指令會處理 → 不吵
879
+ continue
880
+ for m in pp.memories:
881
+ if m.action in _NUDGE_UPDATE_ACTIONS:
882
+ updates += 1
883
+ elif m.action in merge_mod.CONFLICT_ACTIONS:
884
+ conflicts += 1
885
+ if not updates and not conflicts:
886
+ return None
887
+ if updates and conflicts:
888
+ return (f"claude-session-sync:記憶待同步({updates} 更新、{conflicts} 衝突)"
889
+ "→ 更新執行 `sync --apply`、衝突執行 `memory-merge`")
890
+ if updates:
891
+ return f"claude-session-sync:{updates} 個記憶更新待同步 → 執行 `sync --apply`"
892
+ return f"claude-session-sync:{conflicts} 個記憶衝突待處理 → 執行 `memory-merge`"
893
+
894
+
895
+ def _compute_nudge(args) -> str | None:
896
+ """唯讀算出 nudge 訊息。**掛載點不在/未設定/halt → None(不 nudge)**。任何例外由 `_cmd_nudge` 靜默吞。"""
897
+ cfg = config_mod.load() # 壞 config → ConfigError → 上層靜默
898
+ hub = args.hub or cfg.own_hub
899
+ if not hub or not Path(hub).is_dir(): # 未設定 own_hub、或掛載點不在(G5 載體可有可無)→ 不 nudge
900
+ return None
901
+ st = state_mod.load_or_none(args.state) # 壞 state → StateCorruptError → 上層靜默
902
+ local_root = args.local_root or str(default_local_root())
903
+ plan = scan.build_plan(local_root, hub, st, memory_only=True) # 不做重活:跳過 session 分類
904
+ if plan.halt: # 掛錯碟/指紋異常等 → 交 routine sync/status 處理,nudge 不吵
905
+ return None
906
+ return _nudge_summary(plan)
907
+
908
+
909
+ def _cmd_nudge(args) -> int:
910
+ """SessionEnd/SessionStart hook 助手(DESIGN §7.5):**唯讀、fail-silent**,掛載點在才比對 memory,有分歧
911
+ 印一行提示。預設輸出 JSON `{"systemMessage": …}`(Claude Code 對使用者顯示;SessionEnd stdout 本身不顯示、
912
+ 但 systemMessage 通用顯示,SessionStart 亦可);`--text` 改印純文字(手動執行/除錯用)。
913
+
914
+ **絕不干擾 session 結束**:任何錯誤/未設定/掛載點不在/halt/無分歧 → 靜默 exit 0。**輸出也在 try 內**——
915
+ 否則 hook 子程序若 stdout 非 UTF-8(如 `PYTHONIOENCODING=ascii`/cp1252)印中文會拋 `UnicodeEncodeError`、
916
+ 以 traceback 非零退出=破壞 fail-silent(codex R1 Medium)。JSON 用預設 `ensure_ascii=True`(純 ASCII 的
917
+ `\\uXXXX`、任何 stdout 編碼都印得出、Claude Code JSON parser 還原成中文顯示)→ 主 hook 路徑最穩、提示不被
918
+ 默默吞掉;`--text` 原樣中文在無法編碼的終端會拋 → 由 try 靜默吞(手動除錯的邊角情況)。不寫、不鎖、不讀
919
+ stdin(hook 傳的 JSON 不需要、也避免手動執行時卡等 stdin)。"""
920
+ try:
921
+ msg = _compute_nudge(args)
922
+ if msg:
923
+ line = msg if getattr(args, "text", False) else json.dumps({"systemMessage": msg})
924
+ sys.stdout.write(line + "\n")
925
+ sys.stdout.flush() # flush 也在 try 內:延後到 flush 才觸發的編碼錯也要吞
926
+ except BrokenPipeError:
927
+ # hook supervisor 提早關掉我們的 stdout → 把 stdout fd 導向 devnull,避免直譯器**關閉時**再 flush 殘留
928
+ # buffer 又撞破管、令行程以非零/「Exception ignored」收場(g1 Low)。**開的 fd 必 close**(finally),
929
+ # 免每次呼叫漏一個 devnull fd(g2 Low);fileno()/dup2 失敗(測試用非真實 fd)→ 由外層 except 吞。
930
+ try:
931
+ fd = os.open(os.devnull, os.O_WRONLY)
932
+ try:
933
+ os.dup2(fd, sys.stdout.fileno())
934
+ finally:
935
+ os.close(fd)
936
+ except Exception: # noqa: BLE001
937
+ pass
938
+ return 0
939
+ except Exception: # noqa: BLE001 — 建議性指令:任何失敗都不得吵/崩,一律靜默退出 0(hook 不可中斷 session)
940
+ return 0
941
+ return 0
942
+
943
+
944
+ def main(argv: list[str] | None = None) -> int:
945
+ p = argparse.ArgumentParser(prog="claude-session-sync", description="跨機同步 Claude Code session/memory")
946
+ sub = p.add_subparsers(dest="cmd", required=True)
947
+
948
+ _add_common(sub.add_parser("status", help="顯示與 hub 的差異(唯讀)"))
949
+
950
+ sp_sync = sub.add_parser("sync", help="同步(預設 dry-run;--apply 安全寫入)")
951
+ _add_common(sp_sync)
952
+ sp_sync.add_argument("--apply", action="store_true",
953
+ help="實際寫入(自動套用 identical/ff/copy;偵測到本機刪除寫 tombstone 通知對側)")
954
+ sp_sync.add_argument("--interactive", action="store_true",
955
+ help="搭配 --apply:對 fork/superset 互動 union/keep-both(寫 keep-both 新檔,不覆蓋)")
956
+
957
+ sp_bs = sub.add_parser("bootstrap", help="建立同步基線(首次同步;不複製/不刪)")
958
+ _add_common(sp_bs)
959
+ sp_bs.add_argument("--map", action="append", metavar="LOCAL=HUB", help="明示 local夾名→hub夾名(可重複)")
960
+ sp_bs.add_argument("--ignore", action="append", metavar="SID", help="排除某 sid 不傳播(寫 suppress tombstone,可重複)")
961
+ sp_bs.add_argument("--yes", action="store_true", help="確認落地(否則只預覽)")
962
+
963
+ sp_pull = sub.add_parser("pull", help="跨群:remote hub → local(明確、可挑選;預設 dry-run)")
964
+ _add_common(sp_pull)
965
+ sp_pull.add_argument("--from", dest="from_remote", metavar="REMOTE", help="來源 remote 名(config [remotes])")
966
+ sp_pull.add_argument("--session", metavar="SID", help="只傳此 sessionId(不給則該方向全部)")
967
+ sp_pull.add_argument("--map", action="append", metavar="LOCAL=REMOTE", help="明示 local夾名→remote夾名(可重複)")
968
+ sp_pull.add_argument("--apply", action="store_true", help="實際寫入(僅 copy/ff;C3 不覆蓋 local)")
969
+
970
+ sp_push = sub.add_parser("push", help="跨群:local → remote hub(明確、可挑選;預設 dry-run)")
971
+ _add_common(sp_push)
972
+ sp_push.add_argument("--to", dest="to_remote", metavar="REMOTE", help="目標 remote 名(config [remotes])")
973
+ sp_push.add_argument("--session", metavar="SID", help="只傳此 sessionId(不給則該方向全部)")
974
+ sp_push.add_argument("--map", action="append", metavar="LOCAL=REMOTE", help="明示 local夾名→remote夾名(可重複)")
975
+ sp_push.add_argument("--apply", action="store_true", help="實際寫入 remote hub(僅 copy/ff)")
976
+
977
+ sp_remote = sub.add_parser("remote", help="管理跨群 remote hub")
978
+ rsub = sp_remote.add_subparsers(dest="remote_cmd", required=True)
979
+ r_add = rsub.add_parser("add", help="新增/覆寫一個 remote")
980
+ r_add.add_argument("name")
981
+ r_add.add_argument("path")
982
+ rsub.add_parser("list", help="列出所有 remote")
983
+
984
+ sp_doc = sub.add_parser("doctor", help="診斷 / --rebuild-state / --break-lock")
985
+ _add_common(sp_doc)
986
+ sp_doc.add_argument("--rebuild-state", action="store_true",
987
+ help="由磁碟重建 state(state 損壞救援;hub 側無條件、local 側需 --map;永不碰 tombstone)")
988
+ sp_doc.add_argument("--break-lock", action="store_true",
989
+ help="移除同機已死的 stale 鎖(需 --yes;跨機/存活/無法解析者不動)")
990
+ sp_doc.add_argument("--map", action="append", metavar="LOCAL=HUB",
991
+ help="rebuild-state 用:明示 local夾名→hub夾名 重建 local 基線(可重複)")
992
+ sp_doc.add_argument("--ack-all", action="store_true",
993
+ help="把目前所有 damaged/collision blocked 項標記 acknowledged(需 --yes;審閱後 sync/doctor 不再重報)")
994
+ sp_doc.add_argument("--unack-all", action="store_true",
995
+ help="取消所有 acknowledgement(需 --yes;之後重新回報)")
996
+ sp_doc.add_argument("--show-acked", action="store_true", help="列出目前已 acknowledged 的 damaged/collision 項")
997
+ sp_doc.add_argument("--project", metavar="HUB_DIR", help="ack/unack/show 只限此 hub 專案夾名")
998
+ sp_doc.add_argument("--yes", action="store_true", help="確認落地(rebuild/break-lock/ack/unack 否則只預覽)")
999
+
1000
+ sp_nudge = sub.add_parser(
1001
+ "nudge", help="hook 助手:唯讀檢查 memory 分歧,有就印一行提示(給 SessionEnd/SessionStart hook 用)")
1002
+ _add_common(sp_nudge)
1003
+ sp_nudge.add_argument("--text", action="store_true",
1004
+ help="印純文字(預設印 JSON systemMessage 供 Claude Code hook 對使用者顯示)")
1005
+
1006
+ sp_mm = sub.add_parser("memory-merge", help="memory 衝突:保留兩版到本機暫存 + 產合併提示詞(明文外洩警告)")
1007
+ _add_common(sp_mm)
1008
+ sp_mm.add_argument("--from", dest="from_remote", metavar="REMOTE",
1009
+ help="跨群:偵測本機 ↔ 此 remote hub 的 memory 衝突(config [remotes];不給則 own-hub)")
1010
+ sp_mm.add_argument("--map", action="append", metavar="LOCAL=REMOTE",
1011
+ help="搭配 --from:明示 local夾名→remote夾名配對(可重複;工具未寫 sidecar 故跨群多半需要)")
1012
+ sp_mm.add_argument("--project", metavar="HUB_DIR", help="只看此專案夾名(不給則全部)")
1013
+ sp_mm.add_argument("--apply", action="store_true",
1014
+ help="把兩版保留到本機暫存(memory/ 之外、不同步;含 PROMPT.md);否則只預覽")
1015
+ sp_mm.add_argument("--prompt-stdout", action="store_true",
1016
+ help="把合併提示詞印到 stdout(不寫檔;貼進 Claude 前請先刪減敏感段)")
1017
+ sp_mm.add_argument("--fuzzy", action="store_true",
1018
+ help="列出「同事實、不同檔名」的模糊近似候選(唯讀 advisory;只提示、不自動合併、不寫檔)")
1019
+ sp_mm.add_argument("--fuzzy-threshold", type=float, default=fuzzy_mod.DEFAULT_THRESHOLD,
1020
+ metavar="F", help=f"fuzzy 相似度閾值(0~1,預設 {fuzzy_mod.DEFAULT_THRESHOLD};越低越敏感、誤報越多)")
1021
+ # fuzzy 候選放行(Block B):把使用者放行的候選導進 leak-safe 保留兩版(僅搭配 --fuzzy;一般衝突請用 --apply)。
1022
+ g_fuzzy = sp_mm.add_mutually_exclusive_group()
1023
+ g_fuzzy.add_argument("--stage", action="store_true",
1024
+ help="搭配 --fuzzy:把**所有**候選保留兩版到本機暫存(含 PROMPT.md;memory/ 之外、不同步)")
1025
+ g_fuzzy.add_argument("--interactive", action="store_true",
1026
+ help="搭配 --fuzzy:逐對確認「當同一則、保留兩版?」,只保留放行的候選")
1027
+
1028
+ # nudge 是 fail-silent hook 助手:連 **argparse 用法錯誤/說明**(壞 hook 設定,如 `nudge --bogus`、誤含
1029
+ # `--help`)都不能非零/中斷 session、也不能污染輸出——parse_args 出錯會 `SystemExit(2)`、`--help` 會 SystemExit(0)
1030
+ # (皆在 dispatch 前、_cmd_nudge 的 try 包不到,g1 Medium)。故子指令是 nudge 時:把 argparse 的 usage/error/help
1031
+ # **stdout 與 stderr 都導進記憶體 StringIO**——error 走 stderr(g2 Medium:直觸 real stderr 可能編碼失敗/破管在
1032
+ # SystemExit 前逃出)、`--help`/`-h` 走 **stdout**(g3 Medium:非 JSON help 文字會污染 hook 讀到的 stdout、且若
1033
+ # stdout 已關會繞過 BrokenPipe handler)。**只包 parse_args**(dispatch 在外,正常 nudge 輸出不被吞);攔 SystemExit
1034
+ # **與任何其他例外** → 退 0。(第一 token 必為子指令:本 parser 無子指令前的全域旗標。)
1035
+ raw = (sys.argv[1:] if argv is None else list(argv))
1036
+ nudge_mode = bool(raw) and raw[0] == "nudge"
1037
+ if nudge_mode:
1038
+ try:
1039
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
1040
+ args = p.parse_args(argv)
1041
+ except SystemExit:
1042
+ return 0
1043
+ except Exception: # noqa: BLE001 — 壞參數解析的任何例外都不得中斷 session
1044
+ return 0
1045
+ else:
1046
+ args = p.parse_args(argv)
1047
+ if args.cmd == "bootstrap":
1048
+ return _cmd_bootstrap(args)
1049
+ if args.cmd == "pull":
1050
+ return _cmd_transfer(args, transfer_mod.PULL)
1051
+ if args.cmd == "push":
1052
+ return _cmd_transfer(args, transfer_mod.PUSH)
1053
+ if args.cmd == "remote":
1054
+ return _cmd_remote(args)
1055
+ if args.cmd == "doctor":
1056
+ return _cmd_doctor(args)
1057
+ if args.cmd == "memory-merge":
1058
+ return _cmd_memory_merge(args)
1059
+ if args.cmd == "nudge":
1060
+ return _cmd_nudge(args)
1061
+ return _cmd_status_or_sync(args)
1062
+
1063
+
1064
+ if __name__ == "__main__":
1065
+ sys.exit(main())