dsh-workspace-handoff 0.1.0 → 0.2.0
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.
- package/README.md +42 -3
- package/index.js +377 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
## 功能说明
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
三个工具:
|
|
13
|
+
五个工具:
|
|
15
14
|
|
|
16
15
|
| 工具 | 作用 | 关键能力 |
|
|
17
16
|
|---|---|---|
|
|
18
17
|
| `pack_handoff` | 采集当前工作状态,打成 handoff 包(JSON 返回) | 目标 / 关键决策 / 未完成任务 / 备注;按 glob 展开上下文文件清单(相对路径 + 大小 + mtime + sha256);剪枝依赖与产物目录、符号链接防环、文件数预算;可选带 `.journal` 近期日志;包内含规范化 JSON 的 sha256 完整性摘要 |
|
|
19
18
|
| `migrate_handoff` | 把包迁移到另一个工作区根,只出报告不落盘 | 状态文本内的来源根前缀重映射(容忍斜杠反斜杠混写、Windows 盘符大小写);漂移检测五态(未变 / 已改 / 改名 / 缺失 / 新增);接续结论 clean / renamed / dirty / unchecked |
|
|
20
19
|
| `apply_handoff` | 在目标工作区落地接续产物 | 写 `.handoff/HANDOFF.md` 与 `.handoff/handoff.json`;同名冲突策略 overwrite / skip / versioned;支持 dry_run 只看计划;落地包重算完整性并记录迁移溯源 |
|
|
20
|
+
| `diff_handoff` | 对比两个 handoff 包,只读不落盘(v0.2) | 目标 / 决策 / 任务 / 备注逐项差异;清单文件五态对比(相同 / 内容已变 / 改名移动 / 仅 a 有 / 仅 b 有,缺哈希时保守归入 uncertain);结论 identical / state_diverged / files_diverged / diverged |
|
|
21
|
+
| `merge_handoff` | 合并两个 handoff 包为一个新包(v0.2) | 决策并集去重;任务按文本并集(任一侧完成即完成,可改策略);清单按路径并集、同路径默认较新侧为准;notes 默认拼接;产物可直接 `apply_handoff` 落地 |
|
|
21
22
|
|
|
22
23
|
handoff 包结构(`schema` 为 `handoff/v1`):
|
|
23
24
|
|
|
@@ -31,6 +32,8 @@ handoff 包结构(`schema` 为 `handoff/v1`):
|
|
|
31
32
|
}
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
经 `merge_handoff` 产出的包会多一个 `merged` 字段(两侧来源、较新侧、合并策略),经 `apply_handoff` 落地的包带 `migrated` 字段——两者可并存,便于溯源。
|
|
36
|
+
|
|
34
37
|
设计要点:
|
|
35
38
|
|
|
36
39
|
- **搬的是工作状态,不是消息流水**:`goal` / `decisions` / `open_tasks` 由模型自己声明,插件不读 dsh 会话历史,因此跨宿主、跨版本都能用,也不受会话导出格式变化影响。
|
|
@@ -108,8 +111,34 @@ await tools.apply_handoff({
|
|
|
108
111
|
| `notes` | string | ≤4000 字符,其中绝对路径会被重映射 |
|
|
109
112
|
| `root` / `target_root` | string | 工作区根;`root` 默认当前目录 |
|
|
110
113
|
| `hash` / `check` / `strict` / `dry_run` / `force` / `include_journal` | boolean | 见上 |
|
|
114
|
+
| `a_bundle` / `b_bundle` | string | `diff_handoff` / `merge_handoff` 必填:两份包,JSON 字符串或文件路径 |
|
|
115
|
+
| `goal_from` / `notes_mode` / `file_policy` / `done_policy` | string | `merge_handoff` 合并策略,非法取值直接报错 |
|
|
111
116
|
| `max_files` / `max_hash_bytes` / `journal_limit` / `top` | number | 预算类参数,越界或传字符串会被拒 |
|
|
112
117
|
|
|
118
|
+
### 4. 两份包对比与合并(v0.2)
|
|
119
|
+
|
|
120
|
+
同一件事在两台机器各自推进、或一天内打了多个包时,用 `diff_handoff` 看清差异,用 `merge_handoff` 收拢成一份:
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
const d = JSON.parse(await tools.diff_handoff({
|
|
124
|
+
a_bundle: packOnLaptop, // 第一份包:JSON 字符串或文件路径
|
|
125
|
+
b_bundle: packOnDesktop, // 第二份包
|
|
126
|
+
}))
|
|
127
|
+
// d.verdict: identical / state_diverged / files_diverged / diverged
|
|
128
|
+
// d.state.open_tasks.status_changed: 两边都写过的任务谁改了完成状态
|
|
129
|
+
// d.files.moved: 一侧改名/移动的文件(按内容 sha256 互相认领,一份内容只认领一次)
|
|
130
|
+
|
|
131
|
+
const merged = await tools.merge_handoff({
|
|
132
|
+
a_bundle: packOnLaptop,
|
|
133
|
+
b_bundle: packOnDesktop,
|
|
134
|
+
goal_from: "newer", // newer(默认)/ a / b
|
|
135
|
+
file_policy: "newer", // 同路径冲突以哪侧为准
|
|
136
|
+
})
|
|
137
|
+
await tools.apply_handoff({ bundle: merged, target_root: "F:/new/arch-notes" })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
合并策略默认「谁也不丢」:决策与任务取并集、同一条任务任一侧标记完成即视为完成、文件清单同路径以较新一侧条目为准。notes 默认两侧拼接,超过 4000 字会报错提示改用 `notes_mode=a/b` 取单侧。
|
|
141
|
+
|
|
113
142
|
glob 子集:`*`、`?`、`**`、`[...]`、`{a,b}`;只接受工作区内相对路径,`../` 形式直接报错。
|
|
114
143
|
|
|
115
144
|
## 典型场景
|
|
@@ -119,6 +148,7 @@ glob 子集:`*`、`?`、`**`、`[...]`、`{a,b}`;只接受工作区内相对
|
|
|
119
148
|
- **目录改名或换盘符**:`E:/work/proj` 变成 `F:/new/proj`,简报里的路径自动改写,不必全文手找。
|
|
120
149
|
- **多工作区合并**:把散在两个目录的同一件事收拢时,先对旧目录打包,在新目录迁移时会把「内容相同但路径不同」的文件识别为改名,避免重复劳动。
|
|
121
150
|
- **交付前状态留档**:`apply_handoff` 产出的 `HANDOFF.md` 本身就是一份可交给同事或次日的自己的接续文档。
|
|
151
|
+
- **两台机器并行推进后收拢**:各自 `pack_handoff`,回来 `diff_handoff` 看清谁的哪份文件更新、任务状态差在哪,再 `merge_handoff` 合成一份落地,避免手工比对。
|
|
122
152
|
- **跨工作区风险预估**:`pack_handoff` 的文件清单 + 目标区 `migrate_handoff` 的漂移表,可以先看清「少了哪些、哪些被改过」再决定要不要接续。
|
|
123
153
|
|
|
124
154
|
## 安全边界
|
|
@@ -134,6 +164,12 @@ glob 子集:`*`、`?`、`**`、`[...]`、`{a,b}`;只接受工作区内相对
|
|
|
134
164
|
|
|
135
165
|
## 版本说明
|
|
136
166
|
|
|
167
|
+
v0.2.0:
|
|
168
|
+
|
|
169
|
+
- 新增 `diff_handoff`:两个 handoff 包的逐项对比(目标 / 决策 / 任务 / 备注 + 清单文件五态),一侧缺哈希时保守归入 `uncertain` 不误判;`top` 控制明细截断
|
|
170
|
+
- 新增 `merge_handoff`:决策并集去重、任务并集(`done_policy` 默认任一侧完成即完成)、清单按路径并集(`file_policy` 默认较新侧为准)、notes 拼接超限显式报错;产物重算完整性并带 `merged` 溯源字段,可直接 `apply_handoff` 落地
|
|
171
|
+
- `stateOf` 归一化加固:外部手工包的 `decisions` / `open_tasks` / `journal` 非数组或含脏项时安全降级,diff/merge 不再抛错
|
|
172
|
+
- 断言 160 → 280,`diff_handoff` / `merge_handoff` 同样经真实 `@deepseek-ai/dsh-tools` 包装层执行 happy path 与参数校验
|
|
137
173
|
|
|
138
174
|
v0.1.0(首个版本,四期插单):
|
|
139
175
|
|
|
@@ -146,7 +182,7 @@ v0.1.0(首个版本,四期插单):
|
|
|
146
182
|
## RoadMap
|
|
147
183
|
|
|
148
184
|
|
|
149
|
-
-
|
|
185
|
+
- ~~**v0.2**:`diff_handoff` 两包对比 + `merge_handoff` 合并~~ ✅ 已完成(0.2.0)
|
|
150
186
|
- **v0.3**:状态自动提炼——从 `.journal`、todo 与最近改动的文件推断 `goal` / `open_tasks`,把手填降为确认;支持 `include_context_preview` 带出关键文件前若干行,让简报脱离原目录也可读
|
|
151
187
|
- **v0.4**:真正的文件搬运——可选 `copy_files` 把清单内文件按重映射后的路径复制过去(默认仍只出报告,保持只读安全线)
|
|
152
188
|
- **v0.5**:与 `dsh-mindvault` 打通——`apply_handoff` 后可选把本次接续写成 raw 层溯源页,形成「一次迁移 = 一条可回溯记录」
|
|
@@ -181,6 +217,9 @@ v0.1.0(首个版本,四期插单):
|
|
|
181
217
|
**问:清单能包含二进制文件吗?**
|
|
182
218
|
答:可以入清单(记大小与 sha256),但插件只搬清单不搬内容——它不会复制任何文件。需要把文件本体一起带走,请用 `files` 打清单后自行同步目录,或用 `dsh-xlsx-lite`、`dsh-jsonl-stream` 等针对具体类型处理。
|
|
183
219
|
|
|
220
|
+
**问:`diff_handoff` 会把内容相同但路径不同的文件判成「改名」,`merge_handoff` 会怎么处理?**
|
|
221
|
+
答:diff 按 sha256 互相认领,判为 `moved` 给出 from→to 对照;merge 则按路径并集——两个路径都会保留在合并包清单里(同一份内容各一条记录),落地后的 `HANDOFF.md` 清单会同时列出,由接续方自行取舍。这比强行猜一个「正确路径」安全。
|
|
222
|
+
|
|
184
223
|
**问:为什么 `.git`、`node_modules`、`.handoff` 默认不进清单?**
|
|
185
224
|
答:依赖可重装、产物可重建、`.handoff` 是本插件的输出,三者进清单只会造成噪声和自引用。需要改变范围时传 `prune`。
|
|
186
225
|
|
package/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* dsh-workspace-handoff —— 工作区会话迁移插件(零运行时依赖、确定性)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 五个工具:
|
|
5
5
|
* - pack_handoff 采集当前工作区的一次「工作状态」:目标 / 关键决策 / 未完成任务 / 备注 + 上下文文件清单(glob 展开、sha256 指纹),打成可迁移 handoff 包
|
|
6
6
|
* - migrate_handoff 把 handoff 包迁移到另一个工作区根:文本内绝对路径重映射 + 漂移检测(未变/已改/改名/缺失/新增),只出报告不落盘
|
|
7
7
|
* - apply_handoff 在目标工作区落地 HANDOFF.md + handoff.json(覆盖/跳过/版本化三种冲突策略,支持 dry-run,永不删除任何文件)
|
|
8
|
+
* - diff_handoff 对比两个 handoff 包(v0.2):目标/决策/任务/清单文件逐项差异,只读不落盘
|
|
9
|
+
* - merge_handoff 合并两个 handoff 包为一个新包(v0.2):决策并集、任务并集(done 优先)、清单按新者为准,产物可直接交给 apply_handoff
|
|
8
10
|
*
|
|
9
11
|
* 口径(v0.1,README 详述):
|
|
10
12
|
* - 会话「内容」由模型自己声明(goal/decisions/open_tasks/notes)——本插件不读 dsh 会话历史,
|
|
@@ -22,7 +24,8 @@ export const name = "dsh-workspace-handoff"
|
|
|
22
24
|
export const inject = ["tools"]
|
|
23
25
|
|
|
24
26
|
const SCHEMA = "handoff/v1"
|
|
25
|
-
const PLUGIN_VERSION = "0.
|
|
27
|
+
const PLUGIN_VERSION = "0.2.0"
|
|
28
|
+
const MAX_NOTES = 4000
|
|
26
29
|
const DEFAULT_MAX_FILES = 2000
|
|
27
30
|
const HARD_MAX_FILES = 20000
|
|
28
31
|
const DEFAULT_MAX_HASH_BYTES = 4 * 1024 * 1024
|
|
@@ -865,6 +868,329 @@ export function applyHandoff(args) {
|
|
|
865
868
|
}
|
|
866
869
|
}
|
|
867
870
|
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// diff_handoff / merge_handoff(v0.2)
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
function capList(arr, top) {
|
|
876
|
+
return { total: arr.length, items: arr.slice(0, top) }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function bundleTime(b) {
|
|
880
|
+
const t = Date.parse(b.created_at || "")
|
|
881
|
+
return Number.isFinite(t) ? t : 0
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function fileList(b) {
|
|
885
|
+
return (b.context && Array.isArray(b.context.files) ? b.context.files : []).filter((f) => f && typeof f.path === "string")
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function stateOf(b) {
|
|
889
|
+
const st = b.state || {}
|
|
890
|
+
return {
|
|
891
|
+
goal: typeof st.goal === "string" ? st.goal : "",
|
|
892
|
+
decisions: strArr(st.decisions),
|
|
893
|
+
open_tasks: taskArr(st.open_tasks),
|
|
894
|
+
notes: typeof st.notes === "string" ? st.notes : "",
|
|
895
|
+
journal: journalArr(st.journal),
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function arrOf(x) {
|
|
900
|
+
return Array.isArray(x) ? x : []
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function strArr(x) {
|
|
904
|
+
return arrOf(x).filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim())
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function taskArr(x) {
|
|
908
|
+
return arrOf(x)
|
|
909
|
+
.filter((t) => t && typeof t === "object" && typeof t.text === "string" && t.text.trim() !== "")
|
|
910
|
+
.map((t) => ({ text: t.text.trim(), done: Boolean(t.done) }))
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function journalArr(x) {
|
|
914
|
+
return arrOf(x).filter((j) => j && typeof j === "object")
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function choice(value, allowed, label) {
|
|
918
|
+
const v = value === undefined || value === null || value === "" ? undefined : String(value)
|
|
919
|
+
if (v === undefined) return undefined
|
|
920
|
+
if (!allowed.includes(v)) throw new Error(`${label} 只能是 ${allowed.join(" / ")}(收到 ${v})`)
|
|
921
|
+
return v
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 对比两个 handoff 包(只读不落盘):目标/决策/任务/备注逐项差异 + 清单文件五态
|
|
926
|
+
* (same / content_changed / moved / only_a / only_b;一方缺哈希时归 uncertain 保守列出)。
|
|
927
|
+
* 改名识别:某侧独有的路径,若内容能在另一侧独有路径中找到同 sha256 则判 moved,一份内容只认领一次。
|
|
928
|
+
*/
|
|
929
|
+
export function diffHandoff(args) {
|
|
930
|
+
const a = parseBundle(args.a_bundle, process.cwd())
|
|
931
|
+
const b = parseBundle(args.b_bundle, process.cwd())
|
|
932
|
+
const strict = args.strict === undefined ? true : Boolean(args.strict)
|
|
933
|
+
const ia = verifyIntegrity(a)
|
|
934
|
+
const ib = verifyIntegrity(b)
|
|
935
|
+
if (strict && (!ia.ok || !ib.ok)) {
|
|
936
|
+
const who = !ia.ok && !ib.ok ? "a/b 两侧" : !ia.ok ? "a 侧" : "b 侧"
|
|
937
|
+
throw new Error(`bundle 完整性校验失败(${who})。确认是对比手工编辑过的包可传 strict=false 放行并标注`)
|
|
938
|
+
}
|
|
939
|
+
const top = clampNumber(args.top, TOP_DEFAULT, 1, TOP_CAP, "top")
|
|
940
|
+
|
|
941
|
+
const sa = stateOf(a)
|
|
942
|
+
const sb = stateOf(b)
|
|
943
|
+
|
|
944
|
+
const decisions = {
|
|
945
|
+
common: sb.decisions.filter((d) => new Set(sa.decisions).has(d)).length,
|
|
946
|
+
only_a: sa.decisions.filter((d) => !new Set(sb.decisions).has(d)),
|
|
947
|
+
only_b: sb.decisions.filter((d) => !new Set(sa.decisions).has(d)),
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const mapA = new Map(sa.open_tasks.map((t) => [t.text, t]))
|
|
951
|
+
const mapB = new Map(sb.open_tasks.map((t) => [t.text, t]))
|
|
952
|
+
const tasks = { in_both: 0, only_a: [], only_b: [], status_changed: [] }
|
|
953
|
+
for (const [text, ta] of mapA) {
|
|
954
|
+
const tb = mapB.get(text)
|
|
955
|
+
if (!tb) tasks.only_a.push({ text, done: ta.done })
|
|
956
|
+
else {
|
|
957
|
+
tasks.in_both++
|
|
958
|
+
if (ta.done !== tb.done) tasks.status_changed.push({ text, a_done: ta.done, b_done: tb.done })
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
for (const [text, tb] of mapB) if (!mapA.has(text)) tasks.only_b.push({ text, done: tb.done })
|
|
962
|
+
|
|
963
|
+
const journalKey = (j) => `${j && j.time ? j.time : ""}|${j && j.body ? j.body : ""}`
|
|
964
|
+
const jA = new Set(sa.journal.map(journalKey))
|
|
965
|
+
const jB = new Set(sb.journal.map(journalKey))
|
|
966
|
+
const journal = {
|
|
967
|
+
a_count: sa.journal.length,
|
|
968
|
+
b_count: sb.journal.length,
|
|
969
|
+
common: [...jA].filter((k) => jB.has(k)).length,
|
|
970
|
+
only_a: [...jA].filter((k) => !jB.has(k)).length,
|
|
971
|
+
only_b: [...jB].filter((k) => !jA.has(k)).length,
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const fa = fileList(a)
|
|
975
|
+
const fb = fileList(b)
|
|
976
|
+
const pa = new Map(fa.map((f) => [f.path, f]))
|
|
977
|
+
const pb = new Map(fb.map((f) => [f.path, f]))
|
|
978
|
+
const files = { same: [], content_changed: [], moved: [], only_a: [], only_b: [], uncertain: [] }
|
|
979
|
+
const aOnly = fa.filter((f) => !pb.has(f.path))
|
|
980
|
+
const bOnly = fb.filter((f) => !pa.has(f.path))
|
|
981
|
+
const bClaimed = new Set()
|
|
982
|
+
|
|
983
|
+
for (const f of fa) {
|
|
984
|
+
const g = pb.get(f.path)
|
|
985
|
+
if (!g) continue
|
|
986
|
+
if (f.sha256 && g.sha256) {
|
|
987
|
+
if (f.sha256 === g.sha256) files.same.push({ path: f.path, sha256: f.sha256 })
|
|
988
|
+
else files.content_changed.push({ path: f.path, a_sha256: f.sha256.slice(0, 12), b_sha256: g.sha256.slice(0, 12), a_size: f.size, b_size: g.size })
|
|
989
|
+
} else if (!f.sha256 && !g.sha256) {
|
|
990
|
+
if (f.size === g.size) files.same.push({ path: f.path, by: "size(两侧均未记录哈希)" })
|
|
991
|
+
else files.content_changed.push({ path: f.path, reason: "size 不符(两侧均未记录哈希)", a_size: f.size, b_size: g.size })
|
|
992
|
+
} else {
|
|
993
|
+
files.uncertain.push({ path: f.path, reason: "一侧未记录 sha256,无法判内容异同", a_size: f.size, b_size: g.size })
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const bOnlyByDigest = new Map()
|
|
998
|
+
for (const f of bOnly) if (f.sha256 && !bOnlyByDigest.has(f.sha256)) bOnlyByDigest.set(f.sha256, f)
|
|
999
|
+
for (const f of aOnly) {
|
|
1000
|
+
const hit = f.sha256 ? bOnlyByDigest.get(f.sha256) : null
|
|
1001
|
+
if (hit && !bClaimed.has(hit.path)) {
|
|
1002
|
+
bClaimed.add(hit.path)
|
|
1003
|
+
files.moved.push({ from: f.path, to: hit.path, sha256: f.sha256 })
|
|
1004
|
+
} else files.only_a.push({ path: f.path, size: f.size })
|
|
1005
|
+
}
|
|
1006
|
+
for (const f of bOnly) if (!bClaimed.has(f.path)) files.only_b.push({ path: f.path, size: f.size })
|
|
1007
|
+
|
|
1008
|
+
const stateDiff =
|
|
1009
|
+
(sa.goal === sb.goal ? 0 : 1) +
|
|
1010
|
+
(sa.notes === sb.notes ? 0 : 1) +
|
|
1011
|
+
decisions.only_a.length +
|
|
1012
|
+
decisions.only_b.length +
|
|
1013
|
+
tasks.only_a.length +
|
|
1014
|
+
tasks.only_b.length +
|
|
1015
|
+
tasks.status_changed.length
|
|
1016
|
+
const filesDiff =
|
|
1017
|
+
files.content_changed.length + files.moved.length + files.only_a.length + files.only_b.length + files.uncertain.length
|
|
1018
|
+
|
|
1019
|
+
let verdict, verdict_note
|
|
1020
|
+
if (stateDiff === 0 && filesDiff === 0) {
|
|
1021
|
+
verdict = "identical"
|
|
1022
|
+
verdict_note = "两包状态与清单完全一致,无需合并"
|
|
1023
|
+
} else if (filesDiff === 0) {
|
|
1024
|
+
verdict = "state_diverged"
|
|
1025
|
+
verdict_note = `清单一致,状态有 ${stateDiff} 处差异,可按 merge_handoff 取并集`
|
|
1026
|
+
} else if (stateDiff === 0) {
|
|
1027
|
+
verdict = "files_diverged"
|
|
1028
|
+
verdict_note = `状态一致,清单有 ${filesDiff} 处差异`
|
|
1029
|
+
} else {
|
|
1030
|
+
verdict = "diverged"
|
|
1031
|
+
verdict_note = `状态 ${stateDiff} 处、清单 ${filesDiff} 处差异,merge 前先看清哪侧为准`
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const at = bundleTime(a)
|
|
1035
|
+
const bt = bundleTime(b)
|
|
1036
|
+
|
|
1037
|
+
return {
|
|
1038
|
+
schema: SCHEMA,
|
|
1039
|
+
compared: {
|
|
1040
|
+
a: { label: a.source.label, root: normSlashes(a.source.root), created_at: a.created_at, files: fa.length, version: a.producer && a.producer.version },
|
|
1041
|
+
b: { label: b.source.label, root: normSlashes(b.source.root), created_at: b.created_at, files: fb.length, version: b.producer && b.producer.version },
|
|
1042
|
+
newer: at === bt ? "same" : bt > at ? "b" : "a",
|
|
1043
|
+
},
|
|
1044
|
+
integrity: { a: ia, b: ib },
|
|
1045
|
+
state: {
|
|
1046
|
+
goal: { same: sa.goal === sb.goal, a: sa.goal, b: sb.goal },
|
|
1047
|
+
notes: { same: sa.notes === sb.notes, a_length: sa.notes.length, b_length: sb.notes.length },
|
|
1048
|
+
decisions: { common: decisions.common, only_a: capList(decisions.only_a, top), only_b: capList(decisions.only_b, top) },
|
|
1049
|
+
open_tasks: {
|
|
1050
|
+
in_both: tasks.in_both,
|
|
1051
|
+
status_changed: capList(tasks.status_changed, top),
|
|
1052
|
+
only_a: capList(tasks.only_a, top),
|
|
1053
|
+
only_b: capList(tasks.only_b, top),
|
|
1054
|
+
},
|
|
1055
|
+
journal,
|
|
1056
|
+
},
|
|
1057
|
+
files: {
|
|
1058
|
+
same: capList(files.same, top),
|
|
1059
|
+
content_changed: capList(files.content_changed, top),
|
|
1060
|
+
moved: capList(files.moved, top),
|
|
1061
|
+
only_a: capList(files.only_a, top),
|
|
1062
|
+
only_b: capList(files.only_b, top),
|
|
1063
|
+
uncertain: capList(files.uncertain, top),
|
|
1064
|
+
},
|
|
1065
|
+
counts: { state_diff: stateDiff, files_diff: filesDiff },
|
|
1066
|
+
verdict,
|
|
1067
|
+
verdict_note,
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* 合并两个 handoff 包为一个新包(纯内存、不落盘,产物可直接交给 apply_handoff 落地):
|
|
1073
|
+
* - goal 默认取较新一侧(可 goal_from=a/b)
|
|
1074
|
+
* - decisions 取并集去重(a 在前、b 补新)
|
|
1075
|
+
* - open_tasks 按文本并集,同一条任一侧 done 即视为完成(可 done_policy=a/b 按单侧为准)
|
|
1076
|
+
* - notes 默认两侧拼接(超 4000 字需改用 notes_mode 取单侧)
|
|
1077
|
+
* - files 按路径并集,同路径默认以较新一侧条目为准(可 file_policy=a/b)
|
|
1078
|
+
* - patterns 并集;合并来源与策略写入包内 merged 字段,integrity 重算
|
|
1079
|
+
*/
|
|
1080
|
+
export function mergeHandoff(args) {
|
|
1081
|
+
const a = parseBundle(args.a_bundle, process.cwd())
|
|
1082
|
+
const b = parseBundle(args.b_bundle, process.cwd())
|
|
1083
|
+
const strict = args.strict === undefined ? true : Boolean(args.strict)
|
|
1084
|
+
const ia = verifyIntegrity(a)
|
|
1085
|
+
const ib = verifyIntegrity(b)
|
|
1086
|
+
if (strict && (!ia.ok || !ib.ok)) {
|
|
1087
|
+
const who = !ia.ok && !ib.ok ? "a/b 两侧" : !ia.ok ? "a 侧" : "b 侧"
|
|
1088
|
+
throw new Error(`bundle 完整性校验失败(${who})。确认无误可传 strict=false 放行(合并包会标注 integrity 不可信)`)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const goalFrom = choice(args.goal_from, ["newer", "a", "b"], "goal_from") || "newer"
|
|
1092
|
+
const notesMode = choice(args.notes_mode, ["both", "a", "b"], "notes_mode") || "both"
|
|
1093
|
+
const filePolicy = choice(args.file_policy, ["newer", "a", "b"], "file_policy") || "newer"
|
|
1094
|
+
const donePolicy = choice(args.done_policy, ["any_done", "a", "b"], "done_policy") || "any_done"
|
|
1095
|
+
|
|
1096
|
+
const at = bundleTime(a)
|
|
1097
|
+
const bt = bundleTime(b)
|
|
1098
|
+
const newer = bt > at ? "b" : "a"
|
|
1099
|
+
const goalPick = goalFrom === "newer" ? newer : goalFrom
|
|
1100
|
+
const filePick = filePolicy === "newer" ? newer : filePolicy
|
|
1101
|
+
|
|
1102
|
+
const sa = stateOf(a)
|
|
1103
|
+
const sb = stateOf(b)
|
|
1104
|
+
|
|
1105
|
+
const goal = (goalPick === "a" ? sa.goal : sb.goal).trim()
|
|
1106
|
+
if (goal === "") throw new Error(`合并后 goal 为空(${goalPick === "a" ? "a" : "b"} 侧缺少 state.goal)`)
|
|
1107
|
+
if (goal.length > 500) throw new Error("goal 长度不得超过 500 字符")
|
|
1108
|
+
|
|
1109
|
+
const decisions = []
|
|
1110
|
+
for (const d of [...sa.decisions, ...sb.decisions]) if (!decisions.includes(d)) decisions.push(d)
|
|
1111
|
+
|
|
1112
|
+
const taskA = new Map(sa.open_tasks.map((t) => [t.text, Boolean(t.done)]))
|
|
1113
|
+
const taskB = new Map(sb.open_tasks.map((t) => [t.text, Boolean(t.done)]))
|
|
1114
|
+
const open_tasks = []
|
|
1115
|
+
for (const text of [...taskA.keys(), ...[...taskB.keys()].filter((x) => !taskA.has(x))]) {
|
|
1116
|
+
const ha = taskA.has(text)
|
|
1117
|
+
const hb = taskB.has(text)
|
|
1118
|
+
let done
|
|
1119
|
+
if (donePolicy === "a") done = ha ? taskA.get(text) : Boolean(taskB.get(text))
|
|
1120
|
+
else if (donePolicy === "b") done = hb ? taskB.get(text) : Boolean(taskA.get(text))
|
|
1121
|
+
else done = Boolean(taskA.get(text)) || Boolean(taskB.get(text))
|
|
1122
|
+
open_tasks.push({ text, done })
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let notes
|
|
1126
|
+
if (notesMode === "a") notes = sa.notes
|
|
1127
|
+
else if (notesMode === "b") notes = sb.notes
|
|
1128
|
+
else notes = sa.notes && sb.notes ? `${sa.notes}\n\n${sb.notes}` : sa.notes || sb.notes
|
|
1129
|
+
if (notes.length > MAX_NOTES) throw new Error(`合并后 notes 超过 ${MAX_NOTES} 字符(实际 ${notes.length}),请改用 notes_mode=a 或 b 取单侧`)
|
|
1130
|
+
|
|
1131
|
+
const jMap = new Map()
|
|
1132
|
+
for (const j of [...sa.journal, ...sb.journal]) {
|
|
1133
|
+
const key = `${j && j.time ? j.time : ""}|${j && j.body ? j.body : ""}`
|
|
1134
|
+
if (j && j.body && !jMap.has(key)) jMap.set(key, j)
|
|
1135
|
+
}
|
|
1136
|
+
const jLimit = clampNumber(args.journal_limit, 50, 1, 200, "journal_limit")
|
|
1137
|
+
const journal = [...jMap.values()].sort((x, y) => String(y.time || "").localeCompare(String(x.time || ""))).slice(0, jLimit)
|
|
1138
|
+
|
|
1139
|
+
const fa = fileList(a)
|
|
1140
|
+
const fb = fileList(b)
|
|
1141
|
+
const mergedFiles = new Map()
|
|
1142
|
+
const secondary = filePick === "a" ? fb : fa
|
|
1143
|
+
const primary = filePick === "a" ? fa : fb
|
|
1144
|
+
for (const f of secondary) mergedFiles.set(f.path, f)
|
|
1145
|
+
for (const f of primary) mergedFiles.set(f.path, f)
|
|
1146
|
+
const files = [...mergedFiles.values()].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0))
|
|
1147
|
+
|
|
1148
|
+
const patterns = []
|
|
1149
|
+
for (const p of [...strArr(a.context.patterns), ...strArr(b.context.patterns)]) if (!patterns.includes(p)) patterns.push(p)
|
|
1150
|
+
|
|
1151
|
+
const saStats = a.context.stats || {}
|
|
1152
|
+
const sbStats = b.context.stats || {}
|
|
1153
|
+
const label = args.label === undefined || String(args.label).trim() === ""
|
|
1154
|
+
? `${(a.source.label || "a")}+${(b.source.label || "b")}`
|
|
1155
|
+
: String(args.label).trim()
|
|
1156
|
+
const primaryBundle = filePick === "a" ? a : b
|
|
1157
|
+
|
|
1158
|
+
const bundle = {
|
|
1159
|
+
schema: SCHEMA,
|
|
1160
|
+
kind: "handoff",
|
|
1161
|
+
created_at: new Date().toISOString(),
|
|
1162
|
+
producer: { plugin: name, version: PLUGIN_VERSION },
|
|
1163
|
+
source: {
|
|
1164
|
+
root: normSlashes(primaryBundle.source.root),
|
|
1165
|
+
platform: primaryBundle.source.platform || process.platform,
|
|
1166
|
+
label,
|
|
1167
|
+
},
|
|
1168
|
+
state: { goal, decisions, open_tasks, notes },
|
|
1169
|
+
context: {
|
|
1170
|
+
patterns,
|
|
1171
|
+
files,
|
|
1172
|
+
stats: {
|
|
1173
|
+
matched: files.length,
|
|
1174
|
+
scanned: (Number(saStats.scanned) || 0) + (Number(sbStats.scanned) || 0),
|
|
1175
|
+
skipped_large: (Number(saStats.skipped_large) || 0) + (Number(sbStats.skipped_large) || 0),
|
|
1176
|
+
hashed: files.length > 0 && files.every((f) => typeof f.sha256 === "string"),
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
merged: {
|
|
1180
|
+
a: { label: a.source.label, root: normSlashes(a.source.root), created_at: a.created_at, files: fa.length, integrity_ok: ia.ok },
|
|
1181
|
+
b: { label: b.source.label, root: normSlashes(b.source.root), created_at: b.created_at, files: fb.length, integrity_ok: ib.ok },
|
|
1182
|
+
newer,
|
|
1183
|
+
policies: { goal_from: goalFrom, notes_mode: notesMode, file_policy: filePolicy, done_policy: donePolicy },
|
|
1184
|
+
},
|
|
1185
|
+
}
|
|
1186
|
+
if (journal.length > 0) bundle.state.journal = journal
|
|
1187
|
+
|
|
1188
|
+
const body = { ...bundle }
|
|
1189
|
+
delete body.integrity
|
|
1190
|
+
bundle.integrity = { algo: "sha256-canonical-v1", digest: sha256Text(canonicalize(body)) }
|
|
1191
|
+
return bundle
|
|
1192
|
+
}
|
|
1193
|
+
|
|
868
1194
|
// ---------------------------------------------------------------------------
|
|
869
1195
|
// 工具定义
|
|
870
1196
|
// ---------------------------------------------------------------------------
|
|
@@ -919,6 +1245,52 @@ const migrateHandoffTool = defineTool({
|
|
|
919
1245
|
},
|
|
920
1246
|
})
|
|
921
1247
|
|
|
1248
|
+
const diffHandoffTool = defineTool({
|
|
1249
|
+
name: "diff_handoff",
|
|
1250
|
+
description:
|
|
1251
|
+
"对比两个 handoff 包(只读不落盘):目标/备注/关键决策/任务清单逐项差异,上下文文件按五态对比"
|
|
1252
|
+
+"(相同 / 内容已变 / 改名移动 / 仅 a 有 / 仅 b 有;缺哈希时保守归入 uncertain),并给出结论"
|
|
1253
|
+
+" identical / state_diverged / files_diverged / diverged。适合比较同一工作区两次 pack 或两个分支的接续包。",
|
|
1254
|
+
parameters: {
|
|
1255
|
+
a_bundle: { type: "string", description: "必填,第一份 handoff 包:JSON 字符串或文件路径(如 .handoff/handoff.json)" },
|
|
1256
|
+
b_bundle: { type: "string", description: "必填,第二份 handoff 包:JSON 字符串或文件路径" },
|
|
1257
|
+
strict: { type: "boolean", description: "可选,任一侧完整性校验失败时是否直接报错(默认 true)" },
|
|
1258
|
+
top: { type: "number", description: "可选,各类明细最多列出条数(1~500,默认 50)" },
|
|
1259
|
+
},
|
|
1260
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
1261
|
+
async execute(args) {
|
|
1262
|
+
if (!args || args.a_bundle === undefined) throw new Error("缺少必填参数 a_bundle")
|
|
1263
|
+
if (!args || args.b_bundle === undefined) throw new Error("缺少必填参数 b_bundle")
|
|
1264
|
+
return JSON.stringify(diffHandoff(args), null, 2)
|
|
1265
|
+
},
|
|
1266
|
+
})
|
|
1267
|
+
|
|
1268
|
+
const mergeHandoffTool = defineTool({
|
|
1269
|
+
name: "merge_handoff",
|
|
1270
|
+
description:
|
|
1271
|
+
"合并两个 handoff 包为一个新包(纯内存不落盘,产物可直接交给 apply_handoff 落地或继续 diff):"
|
|
1272
|
+
+"关键决策取并集去重;任务按文本并集、同一条任一侧完成即视为完成(done_policy 可改);"
|
|
1273
|
+
+"清单文件按路径并集、同路径默认以较新一侧为准(file_policy 可改);notes 默认两侧拼接。"
|
|
1274
|
+
+"合并来源与策略写入包内 merged 字段,完整性摘要重算。",
|
|
1275
|
+
parameters: {
|
|
1276
|
+
a_bundle: { type: "string", description: "必填,第一份 handoff 包:JSON 字符串或文件路径" },
|
|
1277
|
+
b_bundle: { type: "string", description: "必填,第二份 handoff 包:JSON 字符串或文件路径" },
|
|
1278
|
+
goal_from: { type: "string", description: "可选,合并后 goal 取哪侧:newer(默认,按 created_at)/ a / b" },
|
|
1279
|
+
notes_mode: { type: "string", description: "可选,备注取法:both(默认拼接,超 4000 字需改用单侧)/ a / b" },
|
|
1280
|
+
file_policy: { type: "string", description: "可选,清单同路径冲突以哪侧为准:newer(默认)/ a / b" },
|
|
1281
|
+
done_policy: { type: "string", description: "可选,任务完成状态取法:any_done(默认任一侧完成即完成)/ a / b" },
|
|
1282
|
+
label: { type: "string", description: "可选,合并包的工作区标注(默认 a+b 两侧 label 拼接)" },
|
|
1283
|
+
journal_limit: { type: "number", description: "可选,合并后 journal 条目上限(1~200,默认 50)" },
|
|
1284
|
+
strict: { type: "boolean", description: "可选,任一侧完整性校验失败时是否直接报错(默认 true)" },
|
|
1285
|
+
},
|
|
1286
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
1287
|
+
async execute(args) {
|
|
1288
|
+
if (!args || args.a_bundle === undefined) throw new Error("缺少必填参数 a_bundle")
|
|
1289
|
+
if (!args || args.b_bundle === undefined) throw new Error("缺少必填参数 b_bundle")
|
|
1290
|
+
return JSON.stringify(mergeHandoff(args), null, 2)
|
|
1291
|
+
},
|
|
1292
|
+
})
|
|
1293
|
+
|
|
922
1294
|
const applyHandoffTool = defineTool({
|
|
923
1295
|
name: "apply_handoff",
|
|
924
1296
|
description:
|
|
@@ -952,6 +1324,8 @@ export function apply(ctx) {
|
|
|
952
1324
|
ctx.tools.register(packHandoffTool)
|
|
953
1325
|
ctx.tools.register(migrateHandoffTool)
|
|
954
1326
|
ctx.tools.register(applyHandoffTool)
|
|
1327
|
+
ctx.tools.register(diffHandoffTool)
|
|
1328
|
+
ctx.tools.register(mergeHandoffTool)
|
|
955
1329
|
}
|
|
956
1330
|
|
|
957
|
-
export { packHandoffTool, migrateHandoffTool, applyHandoffTool }
|
|
1331
|
+
export { packHandoffTool, migrateHandoffTool, applyHandoffTool, diffHandoffTool, mergeHandoffTool }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-handoff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Workspace session handoff toolkit for DeepSeek Harness: pack the current working state (goal, decisions, open tasks, notes plus a glob-expanded context file manifest with sha256 fingerprints) into a portable handoff bundle, migrate it to another workspace root with absolute-path remapping and drift detection (unchanged/changed/moved/missing/extra),
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Workspace session handoff toolkit for DeepSeek Harness: pack the current working state (goal, decisions, open tasks, notes plus a glob-expanded context file manifest with sha256 fingerprints) into a portable handoff bundle, migrate it to another workspace root with absolute-path remapping and drift detection (unchanged/changed/moved/missing/extra), apply it as HANDOFF.md + handoff.json with overwrite/skip/versioned conflict modes and dry-run, and since v0.2 diff two bundles (state + five-way file comparison) or merge them into a new bundle (decision/task union, file union with newer-wins policy). Zero dependencies, Node built-ins only, never deletes files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|