WorkBuddy跨端工作
把「一台机器上的工作现场」变成「任意机器都能接着干」。
命令一律形如 $WBS(装好后 = ~/.workbuddy/skills/youchaoyun-cross-device/scripts/wbsync.py):
定位
编排层,不是第二套真相。 全流程只有一个入口 scripts/wbsync.py(实现在 scripts/wbslib/ 包里),它调资料库原生接口完成读写;本技能不复制数据、不另建账本、不缓存副本。
代码按「改一类事看一个文件」分层,改之前先看目录:const(常量与基础设施)→ state(状态/清单/映射)→ cloud(平台接口与编解码)→ local(扫描/分档/形态)→ engine(同步语义与合并)→ feat(账号资产/对话导出)→ cli(子命令)。只允许上层依赖下层。
什么时候用这个技能
该用(用户这么说就命中):「换电脑了」「新设备」「开工」「收工」「今天到这」「同步」「这台也要」「备份到资料库」「从资料库恢复」「哪个技能跨端」「是不是没同步上」「把这次对话也存下来」。
不该用:
- 只是想知道状态 → 走
status(只读),别顺手同步; - 在同一空间里整理 / 移动文件 → 本地操作,与跨端无关;
- 要搬会话表 / 侧栏列表 → 官方 edge-sync 的职责,本技能不碰(对话正文另有
export-chat:只导成 md 随空间走,绝不写回会话表); - 要搬账号授权 / 令牌 / 连接器凭据 → 重新登录即恢复,本技能刻意不搬。
三层资产,两条通道
| 通道 | 对象 | 云端落点 | 方向 |
|---|---|---|---|
| 空间镜像 | 某个本机工作空间(含其中的技能开发源) | 资料库 .youchaoyun-cross-device/spaces/{别名}/ |
双向 |
| 账号资产 | 身份记忆、用户级技能(自建技能快照) | 资料库 .youchaoyun-cross-device/assets/ |
双向 |
| 不搬运 | 同步意图(这台机器要不要同步某个空间 / 某类资产) | 只在本机 ~/.workbuddy/wbsync/intent.json |
— |
| 不搬运 | 凭据 / 连接器授权 / 设备态(缓存、索引、运行时、会话表) | — | — |
判据一句话:凭据不入、设备态不入、有现成跨端通道的不重复搬。
云端只有一个入口(v4.0.0 起):云盘根下唯一的 .youchaoyun-cross-device/,其下 spaces/(空间镜像)、assets/(账号资产)、_deleted/(软删除归档)。目录名一律英文,中文只留在人读的文案里。设备只认一个根 id(缓存在 ~/.workbuddy/wbsync/cloud-root.json)—— 它是导航地址、不是真相,丢了重新发现一次即可。已纳管设备切到新结构必须显式跑一次 migrate-root:锚存在 state.json 与注册表里,不切就是「一半设备写新树、一半写旧树」,而两边都自认为同步正常、不会有任何报错。切完首轮全量重推(新树是空的),旧树等所有设备都切完再由人手工删 —— 资料库没有删节点接口。
换锚后首轮必须全量重推,这不是优化而是正确性(v4.1.0 起由 state[REPUSH_KEY] 保证):push 的前置筛选是「基线 ≠ 本地指纹」,它隐含「云端仍然有该文件」。切到空树后前提不成立,未改动的文件基线照样等于指纹 → 全部漏推,且一行错都不报,只表现为「推了 3 个就报完成」。所以 migrate-root --apply 会给每个单元写下 repushAll = <新锚 id>,带标记那一轮跳过该筛选、推完自动撤销;标记值对不上当前锚时不算数(换锚半途失败/之后又换过一次),此时重跑一次 migrate-root 即可重新打标。
「要不要同步」属意图层(v4.5.0 起),只住本机 ~/.workbuddy/wbsync/intent.json,不上云。它曾以 assets/synclist/selection.json 上过云 —— 那是把它当共享事实同步:别的设备一推就覆盖本机的决定。所以 synclist 整个部件退场(云端那份已移进 assets/_deleted/),老设备跑一次 migrate-intent --apply 就搬过来。它与本机那份「资产清单(本机盘点)」分工不同:意图回答「这台机器要带什么」,盘点回答「这台机器上有什么」(后者只在本机、不上云)。
技能走两层:有市场 slug 的公共技能只记标识(换设备用 find-skills 按 slug 装,不搬本体);无 slug 的自建/私有技能搬本体上云。slug 判定联网查两个市场——lightmake.site(官方源)优先,skillhub.cn(官方补充的社区源)兜底。空间镜像存「技能开发源」(在本机某工作空间里改),账号资产存「已安装自建技能的快照」(~/.workbuddy/skills/,由你逐个勾选)。两者不同目录、不同用途,互不覆盖 —— 镜像里的源可能正改到一半,与「我实际在用的那一版」不是一回事。专家/专家团只记三字段标识(marketplace+name+expert_id)、不搬本体。
对话记录不单独做部件:完工时把每个已纳管空间的对话正文(只留人话,工具调用与命令输出已略)导出成 .workbuddy/sessions/〈日期〉-〈标题〉.md,随空间本身同步 —— 换设备「开工」拉下就能接着聊,这才是「现场还在」。会话正文来自本机 ~/.workbuddy/projects/*/*.jsonl,不依赖原设备在线。任务列表(只有标题、没有正文)曾做成第 4 个部件,已下线:它既显示不了对话、也恢复不了侧栏,属于「看起来有、其实接不上」。
同步分档
| 本机目录类型 | 判定 | 同步范围 |
|---|---|---|
| 普通/命名空间 | 不受 git 管理 | full —— 默认全带产物 + .workbuddy/memory/ + .workbuddy/state.json |
| git 空间 | 受 git 管理(见下) | context —— 只同步 .workbuddy/memory/ + .workbuddy/state.json;产物归 git 管 |
| 日期空间 | 无人命名、随对话增殖 | 不登记、不同步 |
「受 git 管理」怎么判:向上层取最近仓库根(git rev-parse --show-toplevel),再分三种情形 ——
-
不在任何仓库内 → full。
-
仓库根就是本目录 → context。
-
仓库根是本目录的祖先 → 再看本目录是否被该仓库 ignore:被 ignore → full(产物不归它管);未被 ignore → context。
为什么不能只查祖先链上「有没有 .git」:家目录的 dotfiles 仓库、盘根仓库会让整片空间静默降级为 context、产物不再上云,而人以为已经存好了。必须用 ignore 判定把「真的归它管」和「只是碰巧在它下面」区分开。
--scope auto(默认)按上表自动判定;也可显式 --scope full|context 覆盖。status 会报出判定依据(例如「位于仓库 X 内,未被 ignore」)。排除规则见 references/sync-matrix.md。
空间身份:单锚
跨端只认一道锚 —— mirror:云端镜像目录的 node id,写在 {本机空间}/.workbuddy/state.json。
-
目录名 / 本机路径 / 显示名 一律不作锚(各设备不同)。
-
alias只作显示,不参与匹配。 -
锚随
state.json一起同步 → 换设备 pull 一次,锚自动落地。
多空间:标记驱动
只有纳管过的空间才被同步。 纳管(adopt)干两件事:写本机 state.json + 登记进设备级注册表 ~/.workbuddy/wbsync/workspaces.json。
规则统一为 「有锚即已纳管」:
-
adopt写入锚 → 登记(新建与认领都登记) -
space pull显式带--space-id --mirror-id落地 → 一并登记(否则 B 设备拉回的空间会漏出all) -
若不想让它进批量范围,用
all --prune之外没有隐式开关 —— 需要退出请删掉该目录的.workbuddy/state.json
| 策略 | 取舍 |
|---|---|
| 全部自动同步 | ✗ 日期空间随对话增殖,会造出一堆垃圾镜像;大型目录全量上传不可控 |
| 标记后自动同步(采用) | ✓ 标记=纳管过;all 一次遍历全部已纳管空间;未纳管的一律不碰 |
| 每次都要单独指令 | ✗ 多空间时退化成手工,失去意义 |
all push / all pull 只遍历注册表;目录已不存在的登记会提示,--prune 可剔除。
纳管折半(不替人做决定,但替人恢复):云端已有同名镜像 → 同步 自动认领(这是恢复,安全);云端没有 → 不自动新建,等你说一句。纳管归根到底还是人的决定,自动只覆盖「认领已存在的镜像」这一安全子集。
动线
A. 冷启动(新设备第一件事)= 说「开工」一条链
压缩成一条:手工拿到技能 → 说「开工」→ 自动跑完整条链 → 你只补两个手工动作。
-
拿到本技能(唯一不可自动化的自举环):从技能市场安装,或从任一已就绪设备拷贝
cross-device-work/到~/.workbuddy/skills/。新设备必须先有技能才能恢复其余资产 —— 「用技能拉技能」不成立。 -
说「开工」,脚本自动
start:
账号资产取回(云端已有目录则自动接管);随后问一次意图 —— 云端有、本机从没同步过的空间要不要在本机镜像
-
全部已纳管空间取回
-
只读核对,并列出云端镜像候选 —— 你按序号认领(=补纳管)
-
两个只能手工的动作(脚本不碰客户端库):
在客户端对该目录「打开文件夹」一次,空间才进侧栏(侧栏按会话分组,读会话表、不读工作空间记录表,实测写记录表无效)
专家 / 专家团按云端清单里的 xxx@experts 标识在客户端召唤或安装(本工具无安装接口)
- 凭据与连接器:不搬,重新登录 / 授权即恢复。
改动生效时机(重启 vs 热加载)
别为「怎么还没生效」重启客户端 —— 大多数改动是热加载的:
| 改动 | 生效时机 |
|---|---|
| 改本技能的文件(SKILL.md / 脚本 / references) | 下次会话自动读最新版,无需重启客户端(技能按会话加载) |
paths.json(PathMapper 路径映射) |
脚本每次运行现读,立即生效 |
state.json 基线 / workspaces.json 注册表 |
下次同步现读 |
| 云端账号资产目录新建 / 改名 | 下次「同步资产」现读 |
| 侧栏出现新空间 | 只能人工:客户端对该目录「打开文件夹」一次(见上) |
| 客户端数据库(会话表 / 侧栏) | 脚本不写;官方 edge-sync 自管,重启也不由本工具控制 |
B. 先计划,再执行(plan / apply)
任何会写盘的同步之前先跑 plan。 它只读、不写本地、不写云端,秒级返回;云端没动过时 0 内容读取。
wbsync.py plan [--dir <空间>] [--emit decisions.json] [--deep]
wbsync.py apply --decide decisions.json [--dir <空间>]
- 输出:
推 / 取 / 冲突 / 删除四个数 + 必须人裁决的项; --emit把待裁决项写成 JSON(默认值已填:冲突=merge,删除=delete);- 人定完 →
apply --decide一次执行完,不再跑第二遍。
| 决策类型 | 选项 | 默认 |
|---|---|---|
| 冲突(两侧都改) | local / cloud / merge / skip |
merge(仅 doc / csv;其余 skip) |
| 本地已删 | delete / restore / skip |
delete |
merge 走三方合并(base 快照 + git merge-file):干净即落盘;有重叠则把双方存进 .workbuddy/conflicts/<时间>/ 再报人。delete = 墓碑 + 云端节点移入 _deleted/。
结果块(唯一契约):每条命令结束都打印一行 [WBSYNC-RESULT] {...} 和一张表,含 推/取/跳过/冲突/合并/删除/失败/待决策/耗时。照抄这些数,不要自己读自由文本再概括 —— 概括会漏项、会把「跳过」写成「已同步」。退出码:0 全成功、2 部分失败、3 有待裁决、1 致命。
C. 开工(设备级)
方向=云端新→拉。 start:账号资产取回 → 全部已纳管空间取回 → 只读核对。
三条硬行为:
1. 本地已删的不复活([E-DELETED]):基线有、本机无、云端还在 → 不写回,进决策清单;
2. 两侧都改才问:本地没动且云端 version 没变 → 连内容都不读;
3. 本机有未推改动又撞上云端改动 → 拦下,按 plan 的裁决走;业务目录里不再产生 .local-*。
冷启动时(账号部件未配置)若云端已有「账号资产」目录 → 自动接管(只复用,不新建);云端没有 → 跳过并提示说一句「同步资产」。
D. 完工(设备级)
方向=本机新→推。 done:对话正文导出 → 空间推 → 只读核对 → 账号资产核对 → 上云。
「有变才推」:本地没有任何指纹变化时整单元跳过,连云端目录都不遍历。云端更新的项只报不拉(要拉走「开工」)。某一步失败不影响其余步骤。
E. 纳管一个空间(登记进跨端)
「纳管」是唯一入口。认领只认 id,不认名字。
| 参数 | 说明 |
|---|---|
--list |
列出云端分组目录下的镜像候选(目录名 + node id + 内部 alias / 最后同步),不写任何东西 |
--pick N |
按 --list 的序号认领 —— 人只需说「第几个」,不必碰 id |
--mirror-id |
直接用 node id 认领 |
--alias |
人读的别名(=云端目录名)。不取本地目录名;已纳管的目录给了不同 --alias 会原地改名(不改锚) |
--space-id / --group |
缺省取个人空间 / spaces |
--sync auto / pull / push / none |
纳管后是否立即搬运。auto=认领则拉回、新建则不自动推(首次全量上传先看清单) |
--force |
对已纳管的目录重新定锚(会让旧镜像成孤儿,除刻意重建外不要用) |
init 是 adopt 的别名。别名取自 state.json → --alias → 客户端显示名 → 目录名兜底;别名与本地目录名可以不同,不要互推。
B 设备如何拿到 A 建的空间:说「同步 〈空间名〉」或 adopt --list 看候选 → adopt --dir &lt;本机目录&gt; --pick &lt;序号&gt; → 认领并拉回;此后 B 的「完工」自动带上它。不要靠「目录名一样」去认 —— 别名是人读的,各端可以不统一。
F. 批量
完工 / 开工 内置了对全部已纳管空间的批量推 / 拉(all 只遍历注册表,不碰未纳管的)。要单独批量:all push / all pull / all check;目录已不存在的登记会提示,--prune 可剔除。
G. 账号资产(首次配置 / 单独操作)
-
首次必须先出清单让人选,尤其技能类:
scan把技能、身份记忆、专家/专家团(只记标识)分三类列出,技能默认全部不选,由人逐项授权。 -
技能分两层:
scan联网查两个市场(lightmake.site 官方优先、skillhub.cn 社区兜底)判每项「有没有市场 slug」——有 slug 的公共技能只记标识(换设备用find-skills按 slug 装,不搬本体);无 slug 的自建/私有技能才搬本体上云。slug 判定结果缓存到本机inventory/skill-slugs.json(不上云),scan查一次、select复用,不每次推送都联网;查不到的保守落「自建、搬本体」,不丢。 -
skills部件=本机「自建技能」的快照,由你勾选决定,不看它有没有开发源 —— 空间镜像里的源可能正在开发、新版未发布,与「实际在用的那一版」不是一回事。语义是快照 / 回装,不是双向覆盖:推=把自建技能快照上去;拉=一次安装动作(回装前应过技能安全审计)。 -
两个部件(v4.5.0 起):
identity(身份记忆)→skills(用户级技能)。各自独立状态,--part可只操作一件。原synclist部件已退场 —— 它整个目录只为装一份 200 字节的选择清单,却要付「本机目录 + 本机状态 + 本机台账 + 云端目录 + 云端台账节点 + 注册表两行」六份成本;选择现在住本机intent.json。名字仍留在识别表里(LEGACY_PARTS):注册表里那行assets/同步清单若不再被认出,all会把它当普通空间全量推,部件退场反倒让它复活。 -
对话正文导出(
完工自动,或export-chat单独跑):把每个已纳管空间的会话导出成.workbuddy/sessions/〈日期〉-〈标题〉.md,随空间同步。只导该空间下的会话(按会话cwd归属),不纳管目录的会话不上云 —— 那些在另一台机器上接不上。只留人话:reasoning/function_call/function_call_result/ 文件快照整段丢弃,<system-reminder>等注入块剥掉(<user_query>只去标签、留内容)。防膨胀:单条超 20000 字才折叠(留头 3000 + 尾 6000,按行边界回退,不切碎表格/代码块),user消息永不折叠;密钥按行打码(--no-truncate/--no-redact可关)。幂等:jsonl 的 size+mtime 记在空间state.json的chats里,没变就跳过;CHAT_FMT变了则强制重导一次(否则新口径只对以后的对话生效)。当前这条会话也会导出(是快照,你正聊着它就写到当前进度);它还没结束时内容不完整,下一次完工会按变化重写。同目录自动生成INDEX.md(日期/主题/时间跨度/消息数/文件),全量重建、不做增量合并 —— 要改显示名就改那份 md 的首行标题。 -
身份记忆过 PathMapper:
identity部件里的本机绝对路径,推送 / 比对前换成设备无关的 token(默认{home}),拉回前换回本机路径 —— 各设备家目录 / 盘符不同,不换则每次跨端都假冲突、拉回还是错路径。映射住设备级~/.workbuddy/wbsync/paths.json(脚本自维护、不上云、换设备自动重建),不写进 MEMORY.md frontmatter —— 那是人读人改的文件,误改 / 误删会让映射静默失效,同步按错映射搬路径比不同步更糟;且同步反复插拔会不断改动用户文件。空间镜像不过 PathMapper(产物里的路径引用按本机原样)。--no-pathmap可关闭该变换(比对口径随之回到本机形)。 -
清单只记已勾选、不记设备全量:
selection.json只存你勾选过的技能与专家标识 ——remote(市场可查:{名字: {source, slug}})与local(自建:名字数组,只列要搬本体的,不带 true/false)两层;本机「有什么」的全量盘点在inventory/资产清单.md(本机、不上云),随时可scan重建。勾选技能用account select <技能名>,专家用account select <专家名> --expert。 -
清单读不懂就停(v3.2.0 起):读 / 写
selection.json前先判结构形状,只认{remote, local}、{名字: bool}、{名字: {carry}}、空表四种。形状不对、或文件minReader高于本机支持 →[E-SELECTION]拒绝解析且不写盘,提示升级技能。原因是旧版兜底分支会把skills的每个键都当成「要搬本体」,读到将来版本的新结构就会一次性把全部技能推上云。应急开关STRICT_SELECTION = False可回到旧行为(带着该风险,慎用)。 -
台账(v3.3.0 起;v3.6.0 起即判定真相源):每个单元根有一份
_wbsync.ledger.json,记条目指纹、版本向量、墓碑与云端锚cloud_root。它上云、双向、全设备共写同一节点 —— 与只留在本机的state.json分工不同:state.json是缓存,台账是事实。adopt-ledger从旧 state 一次性播种(默认预演,--apply才落盘);正常同步则自动播种并上云,不必手工跑。读不懂或损坏时一律按「无台账」全量核对,绝不据此判「没变」。 - 双算(v3.4.0 起):
ledger-diff把同一判定函数喂两套真相源(state.json与台账),两层都要为 0 —— 记录层(基线 / 云版记录 / 墓碑 / 锚是否记了同一件事)与判定层(每个文件算出的动作类别是否相同)。前者查「有写入点漏走收口」,后者查「语义不等价」。只读:本机台账未落盘时在内存播种,不写盘、不上传。切换前是准入门槛,切换后是回归工具(有差异=收口被绕过)。 - 判定接口(v3.5.0 起):
push/pull/plan三个方向不再各写一套判定,统一走judge.Judge→classify()(唯一规则)→ 动作映射表。改行为改映射表,换真相源换Judge子类 —— 两者不互相牵连。 - 判定走台账(v3.6.0 起):
LedgerJudge是默认(JUDGE_SOURCE = "ledger")。三条必须一起理解:① 判定前先合并云端台账 —— 只读本机那份等于各设备各判各的,那正是旧模型的老毛病;② 写穿:save_unit_state(..., touched=<本轮动过的 rel>)把 state 里的事实对账进台账并上传,touched之外的一律不碰(全量对账会把「他机写过、本机还没拉」的条目误删);③ 墓碑是跨设备事实 —— A 删了文件,B 下次 pull 到墓碑即把本机副本移进.workbuddy/trash/,不复活。回退:环境变量WBSYNC_JUDGE=state(只回退读,写穿照旧,等价于退回 P1 的「只写不读」)。 - 脏标记改用台账节点版本(v3.7.0 起,
_wbsync.epoch.json停用):判断「这个单元云端有没有被动过」看的是台账节点自己的version(上传成功后回填state["ledger"])。旧 epoch 文件不再写入 —— 它按名新建、每台设备一份,A 推完只有 A 那份变,B 读自己的照样判「没变」。函数epoch_touch与常量EPOCH_NAME保留以便回退,云端历史遗留的 epoch 节点由gc报出来、人在网页端清。 -
维护体检
gc(v3.7.0 起):五段 —— 收敛(条目与墓碑并存且云端已无节点 → 撤条目,墓碑留着继续传播)、孤儿(云端有、台账无、本地无 →--apply移进_deleted/,不记墓碑)、丢失(台账有、云端无 →--apply --repush降级为未纳管,下次同步重推)、多份(同名_wbsync.*>1 或遗留 epoch → 只报 id)、僵尸(本机台账镜像 / 登记目录已不存在 → 只报)。默认只读,退出码 1 表示「有待人处理的项」而不是命令失败。 -
取消勾选=停止后续推送,云端旧副本不删(
push本就不删云端);确要清理只能人在网页端处理(脚本无删节点接口)。 -
专家 / 专家团只记三字段标识(
marketplace+name+expert_id)、不搬本体;本工具没有任何安装接口。换设备后按清单里的标识在客户端专家中心召唤或启用。未被召唤过的专家expert_id留空,待召唤后补。
命令速查
五条主命令 + 限定语。主命令表达层级与方向,限定语收窄范围。
| 命令 | 层级 | 作用 |
|---|---|---|
完工(done) |
设备级 | 对话正文导出 → 空间推 → 只读核对 → 账号资产核对 → 上云;方向=本机新→推,云端新只报不拉 |
开工(start) |
设备级 | 账号资产取回 → 空间全拉 → 只读核对;方向=云端新→拉,本地已删不复活 |
计划(plan) |
设备级(缺省)/空间级(--dir) |
只读:推/取/冲突/删除 + 待裁决清单;--emit 写决策文件;--deep 忽略脏标记。通道拒收的项算「受阻」不算「待推」 |
执行(apply) |
同上 | 按 --decide 一次执行完;冲突走裁决,删除走墓碑+软删除 |
同步(sync) |
空间级(默认当前空间) | 未纳管自动认领(云端有镜像);已纳管逐项双向:本机新推、云端新拉、双侧都改拦下 |
对话导出(export-chat) |
设备级(缺省)/空间级(--dir) |
把对话正文写进各空间 .workbuddy/sessions/(只留人话、超长折叠、密钥打码),并重建 INDEX.md |
迁会话(migrate-sessions) |
设备级(缺省)/空间级(--dir) |
默认预演:把会话正文从 .workbuddy/memory/sessions/ 搬到 .workbuddy/sessions/(保 mtime、同名留旧、重写 state.chats 路径) |
同步资产(account sync) |
账号级 | 两部件(记忆→技能)逐项双向 |
意图(intent) |
设备级 | 看这台机器要不要同步某个空间 / 某类资产(list);记一条答复(set space <名> in\|out)。只动本机文件、零网络 |
迁意图(migrate-intent) |
设备级 | 默认预演:把老 selection.json 搬进 intent.json。--apply 才写入(写前留 .bak),原文件保留不删 |
状态(status) |
设备级 | 只读:全部已纳管空间 + 账号资产,分节报差异;含「遗留受阻」一节(首次时间/次数/原因)。不写任何东西 |
体检(gc) |
设备级(缺省)/空间级(--dir) |
默认只读:收敛重复记录、清点孤儿/丢失/多份/僵尸。--apply 才移孤儿与收敛台账,--repush 把丢失项降级重推 |
迁根(migrate-root) |
设备级(缺省)/空间级(--dir) |
默认预演:把锚从旧树(直挂云盘根)切到根入口下。--apply 才建 spaces/{别名}、assets/{部件} 并改锚;切完首轮全量重推 |
限定语:只推 / 只拉(强制单向)· 〈空间名〉 / 这个空间(收窄到某空间)· 资产 / 记忆 / 技能(账号只做某部件)。
| 开关 | 作用 |
|---|---|
--dry-run |
只列计划,不写云端、不改状态 |
--only push / --only pull |
强制单向(等同于说「只推 / 只拉」) |
--scope auto / --scope full |
同步范围:auto=按 .git 判定分档,full=强制全量带产物 |
--sync auto / --sync pull / --sync push / --sync none |
纳管后是否立即搬运 |
--decide <文件> |
apply 按决策文件执行(冲突项 local/cloud/merge/skip,删除项 delete/restore/skip) |
--deep |
plan / check 忽略脏标记,整棵树重扫(用于发现「云端被直接改过」) |
--force |
跳过「有变才推」/ 冲突检测(仅人明确指定才用) |
--part |
同步资产 只操作某一部件(identity / skills) |
--yes |
开工 不问意图,全部记为未定(自动化/管道下本来就自动走这条) |
--no-pathmap |
关闭 PathMapper:身份记忆里的本机路径不做 token 化(默认开启,仅 account 系命令) |
--prune |
all 剔除目录已不存在的登记 |
--repush |
gc 配合 --apply:把「台账有条目、云端无节点」且本地还在的文件降级为未纳管,下次同步重推 |
--skill-dir |
资料库技能目录探测失败时显式指定 |
层级 × 方向
设备级是单向的(一次动全机不下双向覆盖令,安全);局部级(空间 / 账号)才是双向的。
| 只推 | 自动双向 | 只拉 | |
|---|---|---|---|
| 设备级 | 完工 / 完工 只推 | ——(=开工+完工,不设单命令) | 开工 / 开工 只拉 |
| 空间级 | 同步 只推 | 同步 | 同步 只拉 |
| 账号级 | 同步资产 只推 | 同步资产 | 同步资产 只拉 |
决策内核(所有命令共用)
先用版本戳判,再决定要不要读内容。 base=上次同步后的指纹;v=上次写后云端的 version。
| 本机 | 云端 version |
动作 | 读内容? |
|---|---|---|---|
| 未变 | 没变 | 跳过 | 不读 |
| 变了 | 没变 | 推 | 不读 |
| 未变 | 变了 | 拉 | 读 1 次 |
| 变了 | 变了 | 决策项(默认 merge) |
读 1 次 |
| 本地无(基线有) | 仍在 | 不复活(决策项:delete / restore) |
不读 |
| 无版本记录 | — | 按本地权威处理:推 | 不读 |
「自动」的边界:单侧改动全自动;双侧都改必须问;本地删了绝不复活。云端直接改镜像不参与同步(要发现它用 --deep,只报不改)。
说法 → 动作
人不会敲命令,只给一句话;按下表直接执行,不要反问「要不要同步」。开工 / 完工的默认层级已经钉死,不要临场改口径。
封口规则:表外说法(含历史口令、别名、模糊词)一律先问,不得按旧口径执行。
回显层级:执行第一行必打印「层级=设备级/空间级/账号级资产」,你说错时一眼可见 —— 「同步」与「同步资产」仅一词之差,靠回显兜底,不必再换词。
| 用户说 | 层级 | 执行 |
|---|---|---|
| 完工 / 收工 / 今天到这 | 设备级 | done(对话正文导出 → 空间推 → 核对 → 账号资产核对 → 上云) |
| 完工 只推 | 设备级 | done(本就是只推,同左) |
| 开工 / 继续 / 接着做 / 换设备了 / 新电脑 | 设备级 | start(账号资产取回 → 空间全拉 → 核对);本机无纳管空间时自动走冷启动(动线 A) |
| 同步 | 空间级 | sync(未纳管自动认领;已纳管逐项双向) |
| 同步 只推 / 只拉 | 空间级 | sync --only push / --only pull |
| 同步 〈空间名〉/ 这个空间 / 这台设备也要 | 空间级 | sync --dir 〈对应目录〉(未纳管则认领) |
| 同步资产 | 账号级 | account sync(三部件逐项双向) |
| 同步资产 只推 / 只拉 | 账号级 | account push / account pull |
| 同步资产 技能 / 记忆 | 账号级 | account sync --part skills / identity(只做那一件) |
| 把这次对话也存下来 / 连对话一起存 | 空间级 | export-chat --dir 〈对应目录〉(只留人话;当前这条会话也会导出成快照,之后每次完工按变化重写) |
| 状态 | 设备级 | status(全部空间 + 账号资产,只读) |
| 状态 〈空间名〉/ 这个空间 | 空间级 | status --dir 〈对应目录〉(含客户端侧栏那一行) |
| 状态 资产 | 账号级 | account check(三部件差异 + 勾选清单) |
| 哪个技能跨端 / 这个技能也要 / 装了卸了技能 | 账号级 | account scan 刷新盘点 → account select 〈技能名〉 [--off](专家加 --expert)→ 同步资产 |
| 冲突了 / 两边都改了 | — | plan --emit 列出冲突项(含云端 version 与本地时间)→ 人定 local/cloud/merge/skip → apply --decide;不要擅自 --force |
| 本地删了不该回来 | — | plan 报 [E-DELETED];默认 delete(墓碑+移入 _deleted/),要恢复给 restore |
| 侧栏里没有这个空间 | 空间级 | 状态 〈空间名〉 看「客户端侧栏」行 → 报「需打开文件夹一次」时请人在客户端「打开文件夹」(脚本补不了) |
机制与铁律
-
先拉再推、有变才推。 内容无实质差异即跳过(文档比归一化文本,其它文件比字节哈希),不产冗余版本。
-
绝不静默覆盖,也不静默丢失。
pull:本地没动才覆盖;本地有未推改动 → 进决策清单,不写本地。
push:云端 version 证明改过 → 拦下不推,交 plan 裁决。
冲突双方统一存 .workbuddy/conflicts/<时间>/(含 冲突说明.md),不再往业务目录里撒 .local-*。
通道拒收不重复播报。 外链图片 / 超限 / 权限这三类失败跟重试无关,进 state.pending 台账:首次详列原因,之后只打一行「仍受阻(第 N 次)」;plan 把它们算「受阻」而不是「待推」(否则每轮都报同一个「推 1」,人很快就不看输出了)。冲突与无基线待定不进台账 —— 那是等你裁决,必须每轮出现。
-
判序用版本戳,不靠时间戳、也不逐文件读内容。 各端时钟不可信、云端
updatedAt是上传时间。脏标记用台账节点自身的version(v3.7.0 起;此前是每单元一份_wbsync.epoch.json,按名新建导致每台设备各一份):云端未变且本地也无改动 → 整单元 0 内容读取;否则遍历目录树,逐个比version,只有变过的才读内容。脏标记只代表「云端没被别的设备动过」(它由本工具写入,用户在编辑器里删改文件不会更新它),故本地那遍扫描永远要做 —— 少了它,手工删除既不会被报出、云端节点也永远归档不掉。基线指纹存state.json的hashes(非文档)与norms(文档);cloud表存上次写后的version;tombstones存本机已删。三张表都只影响「读不读 / 拉不拉」,不影响指纹,故NORM_VERSION不动。 -
.workbuddy/state.json是易变件:每次同步都重写,不参与跳过判定、冲突判定与基线记录,避免自指死循环。 -
云端镜像两层,不能合并。
node id 是同步标识 —— 机器用、终身不变,写在空间自己的 state.json 并随之同步。
空间别名是人读的显示层 —— 与本地目录名可以不同,两者不得互推;别名只作显示,不参与匹配。
别名取值顺序:state.json 已记录 → --alias → 客户端 displayName → 目录名兜底并提示。
认领只认 id(adopt --mirror-id 或 --pick);按名匹配时重名即报错列候选,绝不静默取第一个。
- 形态映射(云端按扩展名强路由,走错必被拒):
.md/.markdown→ Doc、.csv→ database、.html/.htm→ page、.zip→ 本地留存、其余 → drive。权威规则与全部通道细节(各通道归一化算法、page 不可逆、zip 解包、title 掉扩展名、CSV 数字规范化、Doc 实体编码)见references/sync-matrix.md§3 —— 本处只留执行必须记住的四条:
page 只推不覆盖本地(云端读回是平台改写版;本地是源、云端是发布);
.zip 不上云、不进差异判定(云端解包成散文件,拉不回原包;换设备不恢复);
需要原样保留的编号列(001)不要走 CSV 同步(该通道会规范化纯数字列的表示);
云端 title 掉扩展名:拼路径必须回填 drive 的 ext,否则「每次推都当新文件」、在云端堆重复节点。
-
身份记忆过 PathMapper,frontmatter 不碰:
identity部件的本机绝对路径,推送 / 比对前 canonicalize 成 token、拉回前 expand 回本机路径;映射住设备级~/.workbuddy/wbsync/paths.json(脚本自维护、不上云、换设备自动重建)。不写进 MEMORY.md frontmatter —— 那是人读人改的文件,映射被误改 / 误删会静默失效,同步按错映射搬路径比不同步更糟;且同步反复插拔会不断改动用户文件。token 化后路径差异不再触发假冲突,拉回即得本机正确路径;--no-pathmap可关。口径见动线 F。 -
通道体积预检:超限不传、列册报出:doc / database / page ≤ 50 MiB、drive ≤ 100 MiB。上传前预检拆出超限项,不传、列册报出(rel + 实际大小 + 上限)—— 服务端必拒的上传没有尝试价值,静默跳过更不可取。工具不自动拆文件(拆了就不是同一个产物)。
status/check/push(含--dry-run)都会报,与.zip同构:报出是唯一诚实的做法。 -
目录一比一还原:云端以空间别名为镜像根,其下与空间内目录逐级同名还原;
pull可完整还原;镜像根下不再套空间前缀。 -
云端不删节点:资料库没有硬删接口。本地删掉的文件在云端留下多余节点,由
check报「仅云端有」,人工处置。
同一路径出现多个云端节点必须报出(check / push / pull 都报,并给到各节点 id 与更新时间):这是历史上「每次推都当成新文件」留下的遗留,只能人工在网页端清理;脚本只更新其中较新的那个,不会自愈。
-
目录落盘 ≠ 客户端侧栏出现。侧栏按会话分组(读会话表,不读工作空间记录表),与文件系统无关。本工具不写客户端数据库 —— 往记录表写实测无效(写过 9 行,重启后该空间仍不显示),伪造会话又会污染会话列表。只做只读探测:
status报「已在侧栏」或「需在客户端对该目录『打开文件夹』一次」。这一步只能人工完成,写进动线 A。 -
写本地前金丝雀探测:批量落盘 / 写状态前,先在目标目录建-写-删一个小文件,确认真可写。沙箱「剥写」策略拦截时报
[E-SANDBOX]立即中止 —— 不在搬运中途崩成半成状态(云端推了一半、基线没记、本地写了一半)。各命令幂等,关闭沙箱后重跑同一命令即续传。 -
对话按空间归属、只留人话:导出按会话
cwd归属到已纳管空间,不纳管目录的会话不上云;reasoning/ 工具调用 / 工具结果整段丢弃,注入块剥掉。当前这条会话也导(写的是当下进度的快照)—— 脚本不去猜「我在哪条会话里」,也不按状态过滤,宁可多写一个文件。 -
脚本单一源:只维护技能包内这一份。任何设备级落点都只是它的副本,改了副本不算改。
-
报错照实说:写云端失败时明确告知哪一项没落地,不掩盖、不含糊。输出用稳定错误码(
[E-CONFLICT]/[E-DUP]/[E-LIMIT]/[E-LOCK]/[E-SANDBOX]/[E-PERM]/[E-CLOUD]/[E-STATE])—— 认码不认句子,码与处置的对照见references/error-codes.md。 -
不外露内部细节:用户回复里不出现 node id、原始响应、令牌、临时下载链接。
-
写云端前先出计划:动云端前先
--dry-run;首次给某空间建镜像前,把「范围 + 排除项 + 落点」讲清。 -
表外说法先问:口令表(五条主命令 + 限定语)以外的说法 —— 含历史口令(存一下 / 保存 / 收工 / 继续)、别名、模糊词 —— 一律先问一句确认,不得按旧口径自行执行。旧词对照只记在开发档,SKILL 不保留演变史。