dsh-workspace-handoff 0.0.0-stage → 0.1.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 +192 -2
- package/cordis.patch.yml +3 -0
- package/index.js +957 -0
- package/package.json +42 -4
package/README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-workspace-handoff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
工作区会话迁移插件(零运行时依赖,仅 Node 内置模块)。
|
|
4
|
+
|
|
5
|
+
把一个工作区里「干到哪儿了」打包成可迁移的 handoff 包,搬到另一个工作区根后自动重映射绝对路径、体检文件漂移,并落地成人类可读的 `HANDOFF.md` + 机器可读的 `handoff.json`。适用于换机器、换目录、换盘符,或把 A 仓库的进度接到 B 仓库继续干。
|
|
6
|
+
|
|
7
|
+
- 源码:https://github.com/geeklei/dsh-plugins/tree/main/dsh-workspace-handoff
|
|
8
|
+
- npm:https://www.npmjs.com/package/dsh-workspace-handoff
|
|
9
|
+
- 协议:MIT
|
|
10
|
+
|
|
11
|
+
## 功能说明
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
三个工具:
|
|
15
|
+
|
|
16
|
+
| 工具 | 作用 | 关键能力 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `pack_handoff` | 采集当前工作状态,打成 handoff 包(JSON 返回) | 目标 / 关键决策 / 未完成任务 / 备注;按 glob 展开上下文文件清单(相对路径 + 大小 + mtime + sha256);剪枝依赖与产物目录、符号链接防环、文件数预算;可选带 `.journal` 近期日志;包内含规范化 JSON 的 sha256 完整性摘要 |
|
|
19
|
+
| `migrate_handoff` | 把包迁移到另一个工作区根,只出报告不落盘 | 状态文本内的来源根前缀重映射(容忍斜杠反斜杠混写、Windows 盘符大小写);漂移检测五态(未变 / 已改 / 改名 / 缺失 / 新增);接续结论 clean / renamed / dirty / unchecked |
|
|
20
|
+
| `apply_handoff` | 在目标工作区落地接续产物 | 写 `.handoff/HANDOFF.md` 与 `.handoff/handoff.json`;同名冲突策略 overwrite / skip / versioned;支持 dry_run 只看计划;落地包重算完整性并记录迁移溯源 |
|
|
21
|
+
|
|
22
|
+
handoff 包结构(`schema` 为 `handoff/v1`):
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
{
|
|
26
|
+
schema, kind, created_at, producer,
|
|
27
|
+
source: { root, platform, label },
|
|
28
|
+
state: { goal, decisions, open_tasks, notes, journal? },
|
|
29
|
+
context: { patterns, files[{path,size,mtime,sha256}], stats },
|
|
30
|
+
integrity: { algo, digest }
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
设计要点:
|
|
35
|
+
|
|
36
|
+
- **搬的是工作状态,不是消息流水**:`goal` / `decisions` / `open_tasks` 由模型自己声明,插件不读 dsh 会话历史,因此跨宿主、跨版本都能用,也不受会话导出格式变化影响。
|
|
37
|
+
- **清单只记相对路径**(统一 `/` 分隔):这是换机器、换盘符仍可解的前提;绝对路径只出现在 `source.root` 与文本描述里,迁移时按前缀重映射。
|
|
38
|
+
- **完整性可验**:对不含 `integrity` 的包做键升序规范化 JSON 后取 sha256;字段乱序不影响摘要,手工改内容会被检出。
|
|
39
|
+
|
|
40
|
+
## 安装
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install dsh-workspace-handoff
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
本地开发调试(Debug patch):新建 debug.patch.yml
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
- insert:
|
|
51
|
+
- id: workspace-handoff
|
|
52
|
+
name: "E:/dsh-plugins/dsh-workspace-handoff/index.js"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 使用说明
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
### 1. 在来源工作区打包
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
const bundle = await tools.pack_handoff({
|
|
62
|
+
goal: "续写架构实战合集第 21 篇并设定时发表",
|
|
63
|
+
decisions: ["封面口径沿用浅底蓝字模板", "交付清单为唯一状态来源"],
|
|
64
|
+
open_tasks: ["写完 21 篇", { text: "已发 01 篇", done: true }],
|
|
65
|
+
notes: "源码目录 E:/work/arch-notes/src,配图脚本在 tools 下",
|
|
66
|
+
files: ["**/*.md", "src/**/*.ts"],
|
|
67
|
+
include_journal: true,
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
返回的 JSON 字符串原样保存(写入文件或直接带到另一台机器均可),也可只把 `files` 省掉——那时包内只有状态没有清单。
|
|
72
|
+
|
|
73
|
+
### 2. 在目标工作区迁移
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
const report = await tools.migrate_handoff({
|
|
77
|
+
bundle, // JSON 字符串,或 handoff.json 的路径
|
|
78
|
+
target_root: "F:/new-machine/arch-notes",
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
关注 `verdict`:
|
|
83
|
+
|
|
84
|
+
- `clean` 清单文件全部一致,可直接接续
|
|
85
|
+
- `renamed` 内容都在、只是路径改了(改名或移动),按 `drift.moved` 里的新路径读
|
|
86
|
+
- `dirty` 有缺失或内容变更,接续前先确认原因
|
|
87
|
+
|
|
88
|
+
### 3. 落地接续简报
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
await tools.apply_handoff({
|
|
92
|
+
bundle,
|
|
93
|
+
target_root: "F:/new-machine/arch-notes",
|
|
94
|
+
mode: "versioned",
|
|
95
|
+
dry_run: false,
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
生成 `目标工作区/.handoff/HANDOFF.md`(目标、决策、任务复选框、文件清单表、漂移表、近期日志、备注)与 `handoff.json`。在新工作区里让模型先读 `HANDOFF.md` 即可接续。
|
|
100
|
+
|
|
101
|
+
主要参数:
|
|
102
|
+
|
|
103
|
+
| 参数 | 类型 | 说明 |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `goal` | string | 必填,≤500 字符 |
|
|
106
|
+
| `decisions` / `files` / `prune` | array | 字符串数组;`files` 为 glob |
|
|
107
|
+
| `open_tasks` | array | 元素为字符串,或含 `text` 与 `done` 的对象 |
|
|
108
|
+
| `notes` | string | ≤4000 字符,其中绝对路径会被重映射 |
|
|
109
|
+
| `root` / `target_root` | string | 工作区根;`root` 默认当前目录 |
|
|
110
|
+
| `hash` / `check` / `strict` / `dry_run` / `force` / `include_journal` | boolean | 见上 |
|
|
111
|
+
| `max_files` / `max_hash_bytes` / `journal_limit` / `top` | number | 预算类参数,越界或传字符串会被拒 |
|
|
112
|
+
|
|
113
|
+
glob 子集:`*`、`?`、`**`、`[...]`、`{a,b}`;只接受工作区内相对路径,`../` 形式直接报错。
|
|
114
|
+
|
|
115
|
+
## 典型场景
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
- **换机器接续**:旧机 `pack_handoff` → 把 `handoff.json` 拷到新机 → 新机 `migrate_handoff` 看漂移 → `apply_handoff` 落地简报,模型读简报继续干。
|
|
119
|
+
- **目录改名或换盘符**:`E:/work/proj` 变成 `F:/new/proj`,简报里的路径自动改写,不必全文手找。
|
|
120
|
+
- **多工作区合并**:把散在两个目录的同一件事收拢时,先对旧目录打包,在新目录迁移时会把「内容相同但路径不同」的文件识别为改名,避免重复劳动。
|
|
121
|
+
- **交付前状态留档**:`apply_handoff` 产出的 `HANDOFF.md` 本身就是一份可交给同事或次日的自己的接续文档。
|
|
122
|
+
- **跨工作区风险预估**:`pack_handoff` 的文件清单 + 目标区 `migrate_handoff` 的漂移表,可以先看清「少了哪些、哪些被改过」再决定要不要接续。
|
|
123
|
+
|
|
124
|
+
## 安全边界
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
- **只写两个自有文件**:`apply_handoff` 仅创建或覆盖 `out_dir` 下的 `HANDOFF.md` 与 `handoff.json`,不删除、不修改清单之外的任何既有文件;`migrate_handoff` 全程只读。
|
|
128
|
+
- **不越出工作区**:`out_dir` 必须是目标工作区内的相对路径,绝对路径与含 `..` 段一律拒绝;glob 模式同样禁止越界。
|
|
129
|
+
- **不改文件内容**:路径重映射只作用于 handoff 包内的状态文本,绝不写回源文件。
|
|
130
|
+
- **完整性优先拒绝**:包被手工编辑或传输损坏时,`migrate_handoff` 与 `apply_handoff` 默认报错;确认可信再显式传 `strict: false`(落地还需 `force: true`),且结果里会标注校验失败。
|
|
131
|
+
- **体积有预算**:`max_files` 上限 20000、`max_hash_bytes` 上限 64 MB、bundle 文件上限 8 MB;超限报错或计入 `skipped_large`,不会把大文件读进内存。
|
|
132
|
+
- **不含敏感兜底**:`notes` 与文件清单会如实记录文本与相对路径。若状态描述里含密钥,请自行脱敏后再打包;需要批量脱敏可先用 `dsh-dotenv-toolkit` 的 `mask_env` 或 `dsh-credential-scan`。
|
|
133
|
+
- **符号链接与特殊文件**:遍历跳过符号链接(防环)与非普通文件,不跟随也不报错。
|
|
134
|
+
|
|
135
|
+
## 版本说明
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
v0.1.0(首个版本,四期插单):
|
|
139
|
+
|
|
140
|
+
- `pack_handoff` / `migrate_handoff` / `apply_handoff` 三个工具与 `handoff/v1` 包结构
|
|
141
|
+
- 零依赖 glob 子集编译与遍历、sha256 指纹与规范化 JSON 完整性摘要、绝对路径前缀重映射、五态漂移检测、overwrite / skip / versioned 落地策略与 dry_run
|
|
142
|
+
- 与 `dsh-workspace-journal` 的 `.journal` 约定兼容(可选带出近期条目)
|
|
143
|
+
|
|
144
|
+
160 条断言,经真实 `@deepseek-ai/dsh-tools` 包装层执行每个工具的 happy path 与参数校验。
|
|
145
|
+
|
|
146
|
+
## RoadMap
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
- **v0.2**:`diff_handoff`——两个 handoff 包对比(同一件事在不同机器上各自推进后谁新、状态差异在哪),以及 `merge_handoff` 合并两份任务清单
|
|
150
|
+
- **v0.3**:状态自动提炼——从 `.journal`、todo 与最近改动的文件推断 `goal` / `open_tasks`,把手填降为确认;支持 `include_context_preview` 带出关键文件前若干行,让简报脱离原目录也可读
|
|
151
|
+
- **v0.4**:真正的文件搬运——可选 `copy_files` 把清单内文件按重映射后的路径复制过去(默认仍只出报告,保持只读安全线)
|
|
152
|
+
- **v0.5**:与 `dsh-mindvault` 打通——`apply_handoff` 后可选把本次接续写成 raw 层溯源页,形成「一次迁移 = 一条可回溯记录」
|
|
153
|
+
|
|
154
|
+
不做的事:不读 dsh 会话历史(交给 `dsh-session-exporter`)、不做网络同步(本地文件操作)、不删除任何既有文件。
|
|
155
|
+
|
|
156
|
+
## 其他插件集成说明
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
- **`dsh-session-exporter`**:那边搬「说了什么」(完整消息流水,多种格式导出),这边搬「干到哪儿了」(结构化状态 + 文件清单)。接续时建议先读这边的 `HANDOFF.md`,需要回溯原话再查导出件。
|
|
160
|
+
- **`dsh-workspace-journal`**:`pack_handoff` 的 `include_journal` 直接读它的 `.journal/YYYY-MM.md`,把近期结论随包带走;包里的 journal 段落同样会被重映射。
|
|
161
|
+
- **`dsh-plugin-todo`**:任务清单可从 todo 取,写入 `open_tasks` 的 `{ text, done }` 形式,落地后变成 `HANDOFF.md` 里的复选框。
|
|
162
|
+
- **`dsh-mindvault`**:mindvault 管「知识与记忆页」的长期沉淀,本插件管「一次工作状态」的一次性搬运;`handoff.json` 可作为 raw 层溯源凭证用 `mem_save` 归档。
|
|
163
|
+
- **`dsh-hash-checksum`**:清单里的 `sha256` 与它的输出口径一致(十六进制小写),可互验。
|
|
164
|
+
- **`dsh-file-search` / `dsh-folder-tree`**:漂移表出现 `dirty` 时,用它们定位缺失文件的去向。
|
|
165
|
+
|
|
166
|
+
## FAQ
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
**问:为什么不直接读会话历史,这样要手填 goal 太麻烦?**
|
|
170
|
+
答:v0.1 刻意如此。不依赖宿主会话 API,插件在任何 harness 版本、任何工作区里行为一致,也不受会话结构变更影响;`goal` 一句话的成本远低于跨机器后发现导出格式对不上。自动从会话与 `.journal` 提炼状态已列入 RoadMap。
|
|
171
|
+
|
|
172
|
+
**问:notes 里的路径只替换了来源根,其他绝对路径没改?**
|
|
173
|
+
答:是的,这是设计取舍。插件只替换 `source.root` 前缀,不猜测其他绝对路径——误替换比漏替换危险得多。命中要求前缀之后紧跟分隔符或结束,所以 `E:/work/proj-extra` 不会被 `E:/work/proj` 带走。
|
|
174
|
+
|
|
175
|
+
**问:`verdict` 是 renamed 是什么意思,能直接接续吗?**
|
|
176
|
+
答:内容都在,只是路径变了(文件被改名或移动)。`drift.moved` 给了旧路径到新路径的对照表,按新路径读即可接续。
|
|
177
|
+
|
|
178
|
+
**问:校验失败说摘要不符,但我确实改过 notes。**
|
|
179
|
+
答:改过内容摘要必然变。确认无误后传 `strict: false`(`apply_handoff` 还需 `force: true`)放行,结果里的 `integrity.ok` 会保留 false 作为标注。
|
|
180
|
+
|
|
181
|
+
**问:清单能包含二进制文件吗?**
|
|
182
|
+
答:可以入清单(记大小与 sha256),但插件只搬清单不搬内容——它不会复制任何文件。需要把文件本体一起带走,请用 `files` 打清单后自行同步目录,或用 `dsh-xlsx-lite`、`dsh-jsonl-stream` 等针对具体类型处理。
|
|
183
|
+
|
|
184
|
+
**问:为什么 `.git`、`node_modules`、`.handoff` 默认不进清单?**
|
|
185
|
+
答:依赖可重装、产物可重建、`.handoff` 是本插件的输出,三者进清单只会造成噪声和自引用。需要改变范围时传 `prune`。
|
|
186
|
+
|
|
187
|
+
**问:`mode=versioned` 会一直堆积版本文件吗?**
|
|
188
|
+
答:每次递增 `-v2`、`-v3`……不覆盖旧文件,便于对比与回溯。想收敛就删掉旧版本,插件自身不做清理。
|
|
189
|
+
|
|
190
|
+
## 许可证
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* dsh-workspace-handoff —— 工作区会话迁移插件(零运行时依赖、确定性)。
|
|
3
|
+
*
|
|
4
|
+
* 三个工具:
|
|
5
|
+
* - pack_handoff 采集当前工作区的一次「工作状态」:目标 / 关键决策 / 未完成任务 / 备注 + 上下文文件清单(glob 展开、sha256 指纹),打成可迁移 handoff 包
|
|
6
|
+
* - migrate_handoff 把 handoff 包迁移到另一个工作区根:文本内绝对路径重映射 + 漂移检测(未变/已改/改名/缺失/新增),只出报告不落盘
|
|
7
|
+
* - apply_handoff 在目标工作区落地 HANDOFF.md + handoff.json(覆盖/跳过/版本化三种冲突策略,支持 dry-run,永不删除任何文件)
|
|
8
|
+
*
|
|
9
|
+
* 口径(v0.1,README 详述):
|
|
10
|
+
* - 会话「内容」由模型自己声明(goal/decisions/open_tasks/notes)——本插件不读 dsh 会话历史,
|
|
11
|
+
* 与 dsh-session-exporter(导出消息流水)分工:那边搬「说了什么」,这边搬「干到哪儿了」
|
|
12
|
+
* - 清单里一律记相对路径(统一 "/" 分隔),因此换机器、换盘符、换目录名都可解
|
|
13
|
+
* - 文本重映射只替换来源根前缀,不猜测其他绝对路径;Windows 盘符路径比对大小写不敏感
|
|
14
|
+
* - 完整性 = 对不含 integrity 字段的 bundle 做规范化 JSON 后取 sha256,迁移与应用时校验
|
|
15
|
+
*/
|
|
16
|
+
import { defineTool } from "@deepseek-ai/dsh-tools"
|
|
17
|
+
import fs from "node:fs"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import crypto from "node:crypto"
|
|
20
|
+
|
|
21
|
+
export const name = "dsh-workspace-handoff"
|
|
22
|
+
export const inject = ["tools"]
|
|
23
|
+
|
|
24
|
+
const SCHEMA = "handoff/v1"
|
|
25
|
+
const PLUGIN_VERSION = "0.1.0"
|
|
26
|
+
const DEFAULT_MAX_FILES = 2000
|
|
27
|
+
const HARD_MAX_FILES = 20000
|
|
28
|
+
const DEFAULT_MAX_HASH_BYTES = 4 * 1024 * 1024
|
|
29
|
+
const HARD_MAX_HASH_BYTES = 64 * 1024 * 1024
|
|
30
|
+
const DEFAULT_PRUNE = [".git", "node_modules", ".handoff", ".journal", "dist", "build", "__pycache__"]
|
|
31
|
+
const BUNDLE_MAX_BYTES = 8 * 1024 * 1024
|
|
32
|
+
const TOP_DEFAULT = 50
|
|
33
|
+
const TOP_CAP = 500
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// 路径基础
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** 统一分隔符为 "/",去掉重复分隔符与尾部分隔符(盘符 "C:/x" 形式保留) */
|
|
40
|
+
export function normSlashes(p) {
|
|
41
|
+
return String(p).replace(/[\\/]+/g, "/").replace(/\/+$/, "") || "/"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 根目录解析:相对路径按 cwd 展开,~ 归.home 展开,返回绝对路径 */
|
|
45
|
+
export function resolveRoot(root) {
|
|
46
|
+
if (typeof root !== "string" || root.trim() === "") throw new Error("root 必须为非空字符串")
|
|
47
|
+
const p = root.trim()
|
|
48
|
+
return path.resolve(p.startsWith("~") ? process.env.HOME || process.env.USERPROFILE || p : p)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 判断 abs 是否位于 root 内(含 root 自身);Windows 盘符路径大小写不敏感 */
|
|
52
|
+
export function isWithin(root, abs) {
|
|
53
|
+
const r = normSlashes(root).toLowerCase()
|
|
54
|
+
const a = normSlashes(abs).toLowerCase()
|
|
55
|
+
return a === r || a.startsWith(r + "/")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isWinStyle(p) {
|
|
59
|
+
return /^[A-Za-z]:[\\/]/.test(p) || /^\\\\/.test(p)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 文本内绝对路径重映射:只替换 sourceRoot 前缀(容忍 / 与反斜杠混用、Windows 大小写差异),
|
|
64
|
+
* 保留前缀之后原有的分隔符;后面紧跟路径字符时不算命中(防 dsh-plugins-extra 被误替换)。
|
|
65
|
+
* 返回 { text, count }
|
|
66
|
+
*/
|
|
67
|
+
export function remapText(text, sourceRoot, targetRoot) {
|
|
68
|
+
if (typeof text !== "string" || text === "") return { text, count: 0 }
|
|
69
|
+
const src = normSlashes(sourceRoot)
|
|
70
|
+
const segs = src.split("/").filter((s) => s !== "")
|
|
71
|
+
if (segs.length === 0) return { text, count: 0 }
|
|
72
|
+
let pattern = segs.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]")
|
|
73
|
+
if (src.startsWith("/")) pattern = "/" + pattern
|
|
74
|
+
const flags = isWinStyle(src) || isWinStyle(targetRoot) ? "gi" : "g"
|
|
75
|
+
const re = new RegExp(pattern + "(?![\\w.\\-])", flags)
|
|
76
|
+
let count = 0
|
|
77
|
+
const out = text.replace(re, () => {
|
|
78
|
+
count++
|
|
79
|
+
return targetRoot
|
|
80
|
+
})
|
|
81
|
+
return { text: out, count }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// glob(零依赖子集:** / * / ? / [...] / {a,b})
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const MAGIC_RE = /[*?[\]{}]/
|
|
89
|
+
|
|
90
|
+
/** 编译单个路径段为正则片段:* 匹配非斜杠任意;? 匹配非斜杠单字;[...] 原样;{a,b} 转分支 */
|
|
91
|
+
function compileSegment(seg) {
|
|
92
|
+
let out = ""
|
|
93
|
+
let i = 0
|
|
94
|
+
while (i < seg.length) {
|
|
95
|
+
const ch = seg[i]
|
|
96
|
+
if (ch === "*") {
|
|
97
|
+
out += "[^/]*"
|
|
98
|
+
i++
|
|
99
|
+
} else if (ch === "?") {
|
|
100
|
+
out += "[^/]"
|
|
101
|
+
i++
|
|
102
|
+
} else if (ch === "{") {
|
|
103
|
+
const close = seg.indexOf("}", i)
|
|
104
|
+
if (close === -1) {
|
|
105
|
+
out += "\\{"
|
|
106
|
+
i++
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const opts = seg.slice(i + 1, close).split(",").filter((o) => o !== "")
|
|
110
|
+
if (opts.length === 0) {
|
|
111
|
+
out += "\\{\\}"
|
|
112
|
+
i = close + 1
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
out +=
|
|
116
|
+
"(?:" +
|
|
117
|
+
opts.map((o) => o.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/([^\\])\*/g, "$1[^/]*")).join("|") +
|
|
118
|
+
")"
|
|
119
|
+
i = close + 1
|
|
120
|
+
} else if (ch === "[") {
|
|
121
|
+
const close = seg.indexOf("]", i + 1)
|
|
122
|
+
if (close === -1) {
|
|
123
|
+
out += "\\["
|
|
124
|
+
i++
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
let cls = seg.slice(i, close + 1)
|
|
128
|
+
if (cls[1] === "!") cls = "[^" + cls.slice(2)
|
|
129
|
+
out += cls
|
|
130
|
+
i = close + 1
|
|
131
|
+
} else {
|
|
132
|
+
out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
133
|
+
i++
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 编译 glob 为正则(锚定整串,仅匹配以 "/" 分隔的相对路径):
|
|
141
|
+
* - 末段 ** → 该目录下任意深度至少一层(src 加双星 匹配 src/a、src/a/b,不匹配 src 本身)
|
|
142
|
+
* - 中间双星 → 任意段数(含零段):a + 双星 + b 匹配 a/b 与 a/x/y/b
|
|
143
|
+
*/
|
|
144
|
+
export function compileGlob(pattern) {
|
|
145
|
+
if (typeof pattern !== "string" || pattern.trim() === "") throw new Error("glob 必须为非空字符串")
|
|
146
|
+
const p = normSlashes(pattern).replace(/^\.\//, "")
|
|
147
|
+
const parts = p.split("/").filter((s) => s !== "")
|
|
148
|
+
if (parts.length === 0) throw new Error(`glob "${pattern}" 为空`)
|
|
149
|
+
let re = "^"
|
|
150
|
+
parts.forEach((seg, idx) => {
|
|
151
|
+
const last = idx === parts.length - 1
|
|
152
|
+
if (seg === "**") {
|
|
153
|
+
re += last ? ".+$" : "(?:[^/]+/)*"
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
re += compileSegment(seg)
|
|
157
|
+
if (!last) re += "/"
|
|
158
|
+
})
|
|
159
|
+
re += "$"
|
|
160
|
+
return new RegExp(re)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function matchGlob(pattern, relPath) {
|
|
164
|
+
return compileGlob(pattern).test(normSlashes(relPath))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 遍历 + 匹配:一次 readdir 走到底,跳过符号链接防环,命中数受 max_files 预算约束 */
|
|
168
|
+
export function walkWorkspace(root, patterns, opts = {}) {
|
|
169
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES
|
|
170
|
+
const prune = (opts.prune ?? DEFAULT_PRUNE).map((p) => normSlashes(p).replace(/\/+$/, ""))
|
|
171
|
+
const dirs = []
|
|
172
|
+
const direct = []
|
|
173
|
+
for (const raw of patterns) {
|
|
174
|
+
if (typeof raw !== "string" || raw.trim() === "") throw new Error("glob 模式必须为非空字符串")
|
|
175
|
+
const p = normSlashes(raw).replace(/^\.\//, "")
|
|
176
|
+
if (p.startsWith("../") || p === ".." || p.startsWith("/")) {
|
|
177
|
+
throw new Error(`glob 模式 "${raw}" 越出工作区范围(只允许相对路径)`)
|
|
178
|
+
}
|
|
179
|
+
if (MAGIC_RE.test(p)) dirs.push({ pattern: p, re: compileGlob(p) })
|
|
180
|
+
else direct.push(p)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const found = new Map()
|
|
184
|
+
let scanned = 0
|
|
185
|
+
|
|
186
|
+
for (const d of direct) {
|
|
187
|
+
const abs = path.resolve(root, d.split("/").join(path.sep))
|
|
188
|
+
if (!fs.existsSync(abs)) continue
|
|
189
|
+
const st = fs.statSync(abs)
|
|
190
|
+
if (!st.isFile()) continue
|
|
191
|
+
found.set(d, { size: st.size, mtimeMs: Math.floor(st.mtimeMs) })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (dirs.length > 0) {
|
|
195
|
+
const stack = [root]
|
|
196
|
+
while (stack.length > 0) {
|
|
197
|
+
const cur = stack.pop()
|
|
198
|
+
let ents
|
|
199
|
+
try {
|
|
200
|
+
ents = fs.readdirSync(cur, { withFileTypes: true })
|
|
201
|
+
} catch {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
for (const ent of ents) {
|
|
205
|
+
const abs = path.join(cur, ent.name)
|
|
206
|
+
const rel = normSlashes(path.relative(root, abs))
|
|
207
|
+
if (ent.isSymbolicLink()) continue
|
|
208
|
+
if (ent.isDirectory()) {
|
|
209
|
+
if (prune.some((p) => rel === p || rel.startsWith(p + "/"))) continue
|
|
210
|
+
stack.push(abs)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (!ent.isFile()) continue
|
|
214
|
+
scanned++
|
|
215
|
+
if (!dirs.some((g) => g.re.test(rel))) continue
|
|
216
|
+
if (found.has(rel)) continue
|
|
217
|
+
if (found.size >= maxFiles) throw new Error(`匹配文件数超过上限 max_files=${maxFiles},请收窄 glob 或提高上限`)
|
|
218
|
+
let st
|
|
219
|
+
try {
|
|
220
|
+
st = fs.statSync(abs)
|
|
221
|
+
} catch {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
found.set(rel, { size: st.size, mtimeMs: Math.floor(st.mtimeMs) })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { entries: [...found.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)), scanned }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function hashFile(abs, maxBytes) {
|
|
233
|
+
const st = fs.statSync(abs)
|
|
234
|
+
if (st.size > maxBytes) return { digest: null, size: st.size }
|
|
235
|
+
const h = crypto.createHash("sha256")
|
|
236
|
+
h.update(fs.readFileSync(abs))
|
|
237
|
+
return { digest: h.digest("hex"), size: st.size }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function sha256Text(s) {
|
|
241
|
+
return crypto.createHash("sha256").update(String(s), "utf8").digest("hex")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 递归按键升序排序后序列化,用于稳定摘要(不受字段顺序影响) */
|
|
245
|
+
export function canonicalize(value) {
|
|
246
|
+
if (Array.isArray(value)) return "[" + value.map((v) => canonicalize(v)).join(",") + "]"
|
|
247
|
+
if (value && typeof value === "object") {
|
|
248
|
+
return (
|
|
249
|
+
"{" +
|
|
250
|
+
Object.keys(value)
|
|
251
|
+
.sort()
|
|
252
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k]))
|
|
253
|
+
.join(",") +
|
|
254
|
+
"}"
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
return JSON.stringify(value)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// 参数归一
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
function normalizeTasks(list) {
|
|
265
|
+
if (list === undefined || list === null) return []
|
|
266
|
+
if (!Array.isArray(list)) throw new Error("open_tasks 必须为数组")
|
|
267
|
+
return list.map((item, i) => {
|
|
268
|
+
if (typeof item === "string") {
|
|
269
|
+
const t = item.trim()
|
|
270
|
+
if (t === "") throw new Error(`open_tasks[${i}] 不能为空`)
|
|
271
|
+
return { text: t, done: false }
|
|
272
|
+
}
|
|
273
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
274
|
+
throw new Error(`open_tasks[${i}] 必须为字符串或 {text, done}`)
|
|
275
|
+
}
|
|
276
|
+
const text = typeof item.text === "string" ? item.text.trim() : ""
|
|
277
|
+
if (text === "") throw new Error(`open_tasks[${i}].text 不能为空`)
|
|
278
|
+
if (item.done !== undefined && typeof item.done !== "boolean") throw new Error(`open_tasks[${i}].done 必须为布尔值`)
|
|
279
|
+
return { text, done: Boolean(item.done) }
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeStringList(list, label) {
|
|
284
|
+
if (list === undefined || list === null) return []
|
|
285
|
+
if (!Array.isArray(list)) throw new Error(`${label} 必须为数组`)
|
|
286
|
+
return list.map((item, i) => {
|
|
287
|
+
if (typeof item !== "string" || item.trim() === "") throw new Error(`${label}[${i}] 必须为非空字符串`)
|
|
288
|
+
return item.trim()
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clampNumber(value, def, min, max, label) {
|
|
293
|
+
if (value === undefined || value === null || value === "") return def
|
|
294
|
+
const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value
|
|
295
|
+
if (typeof n !== "number" || !Number.isInteger(n)) throw new Error(`${label} 必须为整数`)
|
|
296
|
+
if (n < min || n > max) throw new Error(`${label} 超出范围 ${min}~${max}`)
|
|
297
|
+
return n
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 读 .journal 近期条目(与 dsh-workspace-journal 的 Markdown 约定兼容) */
|
|
301
|
+
export function readJournal(root, limit) {
|
|
302
|
+
const dir = path.join(root, ".journal")
|
|
303
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
|
|
304
|
+
const files = fs
|
|
305
|
+
.readdirSync(dir)
|
|
306
|
+
.filter((f) => /^\d{4}-\d{2}\.md$/.test(f))
|
|
307
|
+
.sort()
|
|
308
|
+
.reverse()
|
|
309
|
+
.slice(0, 3)
|
|
310
|
+
const items = []
|
|
311
|
+
for (const f of files) {
|
|
312
|
+
const text = fs.readFileSync(path.join(dir, f), "utf8")
|
|
313
|
+
const blocks = text.split(/^###\s+/m).slice(1)
|
|
314
|
+
for (const b of blocks) {
|
|
315
|
+
const nl = b.indexOf("\n")
|
|
316
|
+
const head = (nl === -1 ? b : b.slice(0, nl)).trim()
|
|
317
|
+
const body = (nl === -1 ? "" : b.slice(nl + 1)).trim()
|
|
318
|
+
if (body === "") continue
|
|
319
|
+
const m = head.match(/^(\S+\s+\S+)(?:\s+\[([^\]]*)\])?/)
|
|
320
|
+
items.push({
|
|
321
|
+
time: m ? m[1] : head,
|
|
322
|
+
tags: m && m[2] ? m[2].split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
323
|
+
body,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return items.slice(0, limit)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// pack_handoff
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export function packHandoff(args) {
|
|
335
|
+
const root = resolveRoot(args.root ?? process.cwd())
|
|
336
|
+
const goal = typeof args.goal === "string" ? args.goal.trim() : ""
|
|
337
|
+
if (goal === "") throw new Error("缺少必填参数 goal(一句话说清接下来要干什么)")
|
|
338
|
+
if (goal.length > 500) throw new Error("goal 长度不得超过 500 字符")
|
|
339
|
+
|
|
340
|
+
const notes = args.notes === undefined || args.notes === null ? "" : String(args.notes)
|
|
341
|
+
if (notes.length > 4000) throw new Error("notes 长度不得超过 4000 字符")
|
|
342
|
+
|
|
343
|
+
const patterns = normalizeStringList(args.files, "files")
|
|
344
|
+
const maxFiles = clampNumber(args.max_files, DEFAULT_MAX_FILES, 1, HARD_MAX_FILES, "max_files")
|
|
345
|
+
const maxHash = clampNumber(args.max_hash_bytes, DEFAULT_MAX_HASH_BYTES, 0, HARD_MAX_HASH_BYTES, "max_hash_bytes")
|
|
346
|
+
const doHash = args.hash === undefined ? true : Boolean(args.hash)
|
|
347
|
+
|
|
348
|
+
let entries = []
|
|
349
|
+
let scanned = 0
|
|
350
|
+
if (patterns.length > 0) {
|
|
351
|
+
const walk = walkWorkspace(root, patterns, {
|
|
352
|
+
maxFiles,
|
|
353
|
+
prune: args.prune === undefined ? DEFAULT_PRUNE : normalizeStringList(args.prune, "prune"),
|
|
354
|
+
})
|
|
355
|
+
entries = walk.entries
|
|
356
|
+
scanned = walk.scanned
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const files = []
|
|
360
|
+
let skippedLarge = 0
|
|
361
|
+
for (const [rel, meta] of entries) {
|
|
362
|
+
const abs = path.resolve(root, rel.split("/").join(path.sep))
|
|
363
|
+
const rec = { path: rel, size: meta.size, mtime: meta.mtimeMs }
|
|
364
|
+
if (doHash) {
|
|
365
|
+
const r = hashFile(abs, maxHash)
|
|
366
|
+
if (r.digest) rec.sha256 = r.digest
|
|
367
|
+
else skippedLarge++
|
|
368
|
+
}
|
|
369
|
+
files.push(rec)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const label = args.label === undefined ? path.basename(root) : String(args.label).trim()
|
|
373
|
+
const bundle = {
|
|
374
|
+
schema: SCHEMA,
|
|
375
|
+
kind: "handoff",
|
|
376
|
+
created_at: new Date().toISOString(),
|
|
377
|
+
producer: { plugin: name, version: PLUGIN_VERSION },
|
|
378
|
+
source: { root: normSlashes(root), platform: process.platform, label },
|
|
379
|
+
state: {
|
|
380
|
+
goal,
|
|
381
|
+
decisions: normalizeStringList(args.decisions, "decisions"),
|
|
382
|
+
open_tasks: normalizeTasks(args.open_tasks),
|
|
383
|
+
notes,
|
|
384
|
+
},
|
|
385
|
+
context: {
|
|
386
|
+
patterns,
|
|
387
|
+
files,
|
|
388
|
+
stats: { matched: files.length, scanned, skipped_large: skippedLarge, hashed: doHash },
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (args.include_journal) {
|
|
393
|
+
const limit = clampNumber(args.journal_limit, 10, 1, 100, "journal_limit")
|
|
394
|
+
bundle.state.journal = readJournal(root, limit)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const body = { ...bundle }
|
|
398
|
+
delete body.integrity
|
|
399
|
+
bundle.integrity = { algo: "sha256-canonical-v1", digest: sha256Text(canonicalize(body)) }
|
|
400
|
+
return bundle
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// bundle 解析与完整性
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
export function validateBundle(b) {
|
|
408
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) throw new Error("bundle 必须为对象")
|
|
409
|
+
if (typeof b.schema !== "string" || !b.schema.startsWith("handoff/")) throw new Error(`bundle.schema 缺失或不是 handoff/* 版本(收到 ${b.schema})`)
|
|
410
|
+
if (!b.source || typeof b.source.root !== "string") throw new Error("bundle.source.root 缺失,无法做路径重映射")
|
|
411
|
+
if (!b.state || typeof b.state.goal !== "string") throw new Error("bundle.state.goal 缺失")
|
|
412
|
+
if (!b.context || !Array.isArray(b.context.files)) throw new Error("bundle.context.files 缺失(应为数组,可为空)")
|
|
413
|
+
for (const f of b.context.files) {
|
|
414
|
+
if (!f || typeof f.path !== "string" || typeof f.size !== "number") throw new Error("bundle 清单项必须含 path 与 size")
|
|
415
|
+
}
|
|
416
|
+
return true
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function parseBundle(input, cwdRoot = process.cwd()) {
|
|
420
|
+
if (typeof input !== "string" || input.trim() === "") throw new Error("缺少必填参数 bundle(handoff 包 JSON 字符串或文件路径)")
|
|
421
|
+
const t = input.trim()
|
|
422
|
+
let obj
|
|
423
|
+
if (t.startsWith("{")) {
|
|
424
|
+
try {
|
|
425
|
+
obj = JSON.parse(t)
|
|
426
|
+
} catch (e) {
|
|
427
|
+
throw new Error(`bundle 不是合法 JSON:${e.message}`)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
const abs = path.resolve(cwdRoot, t.split("/").join(path.sep))
|
|
431
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`bundle 文件不存在:${abs}(也可直接传入 JSON 字符串)`)
|
|
432
|
+
const st = fs.statSync(abs)
|
|
433
|
+
if (st.size > BUNDLE_MAX_BYTES) throw new Error(`bundle 文件过大(${st.size} 字节 > ${BUNDLE_MAX_BYTES})`)
|
|
434
|
+
try {
|
|
435
|
+
obj = JSON.parse(fs.readFileSync(abs, "utf8"))
|
|
436
|
+
} catch (e) {
|
|
437
|
+
throw new Error(`bundle 文件不是合法 JSON:${e.message}`)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
validateBundle(obj)
|
|
441
|
+
return obj
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function verifyIntegrity(bundle) {
|
|
445
|
+
if (!bundle.integrity || typeof bundle.integrity.digest !== "string") return { ok: false, reason: "缺少 integrity 字段" }
|
|
446
|
+
const body = { ...bundle }
|
|
447
|
+
delete body.integrity
|
|
448
|
+
const actual = sha256Text(canonicalize(body))
|
|
449
|
+
if (actual !== bundle.integrity.digest) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
reason: "摘要不符(bundle 被手工编辑或传输损坏)",
|
|
453
|
+
expected: bundle.integrity.digest,
|
|
454
|
+
actual,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { ok: true }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// 漂移检测与路径重映射
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
function sameRoot(a, b) {
|
|
465
|
+
const x = normSlashes(a).toLowerCase()
|
|
466
|
+
const y = normSlashes(b).toLowerCase()
|
|
467
|
+
return x === y || x + "/" === y || x === y + "/"
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function existsFile(abs) {
|
|
471
|
+
return fs.existsSync(abs) && fs.statSync(abs).isFile()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 对 bundle 清单逐项判定 unchanged / changed / moved / missing,并按原 patterns 找出目标区新增文件。
|
|
476
|
+
* 改名识别:清单里缺失的路径,其内容若能在目标区「新增文件」中找到同一份 sha256,判为 moved 而非 missing。
|
|
477
|
+
* 前提:patterns 非空(靠它枚举目标区现状);patterns 为空时只按清单路径逐项判定,不做改名反查。
|
|
478
|
+
*/
|
|
479
|
+
export function detectDrift(bundle, targetRoot, top) {
|
|
480
|
+
const entries = bundle.context.files
|
|
481
|
+
const recorded = new Set(entries.map((f) => f.path))
|
|
482
|
+
|
|
483
|
+
const patterns = Array.isArray(bundle.context.patterns) ? bundle.context.patterns : []
|
|
484
|
+
const extraList = []
|
|
485
|
+
let extraTruncated = false
|
|
486
|
+
if (patterns.length > 0) {
|
|
487
|
+
try {
|
|
488
|
+
const walk = walkWorkspace(targetRoot, patterns, { maxFiles: HARD_MAX_FILES })
|
|
489
|
+
for (const [rel, meta] of walk.entries) {
|
|
490
|
+
if (!recorded.has(rel)) extraList.push({ path: rel, size: meta.size, digest: undefined })
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
extraTruncated = true
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** 目标区新增文件里内容等于 digest 的路径(哈希按需计算并缓存;一份新增内容只能被认领一次) */
|
|
498
|
+
const findByDigest = (digest, self) => {
|
|
499
|
+
if (!digest) return null
|
|
500
|
+
for (const e of extraList) {
|
|
501
|
+
if (e.path === self || e.claimed) continue
|
|
502
|
+
if (e.digest === undefined) {
|
|
503
|
+
try {
|
|
504
|
+
e.digest = hashFile(path.resolve(targetRoot, e.path.split("/").join(path.sep)), HARD_MAX_HASH_BYTES).digest
|
|
505
|
+
} catch {
|
|
506
|
+
e.digest = null
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (e.digest && e.digest === digest) {
|
|
510
|
+
e.claimed = true
|
|
511
|
+
return e.path
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const buckets = { unchanged: [], changed: [], moved: [], missing: [], extra: [] }
|
|
518
|
+
let hashed = 0
|
|
519
|
+
|
|
520
|
+
for (const f of entries) {
|
|
521
|
+
const abs = path.resolve(targetRoot, f.path.split("/").join(path.sep))
|
|
522
|
+
if (!existsFile(abs)) {
|
|
523
|
+
const elsewhere = findByDigest(f.sha256, f.path)
|
|
524
|
+
if (elsewhere) {
|
|
525
|
+
buckets.moved.push({ from: f.path, to: elsewhere, sha256: f.sha256 })
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
buckets.missing.push({ path: f.path, size: f.size })
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
const st = fs.statSync(abs)
|
|
532
|
+
if (!f.sha256) {
|
|
533
|
+
if (st.size === f.size && Math.floor(st.mtimeMs) === f.mtime) buckets.unchanged.push({ path: f.path, by: "size/mtime" })
|
|
534
|
+
else buckets.changed.push({ path: f.path, reason: "size/mtime 不符(bundle 未记录哈希)", size: st.size })
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
hashed++
|
|
538
|
+
let cur = null
|
|
539
|
+
try {
|
|
540
|
+
cur = hashFile(abs, HARD_MAX_HASH_BYTES).digest
|
|
541
|
+
} catch {
|
|
542
|
+
cur = null
|
|
543
|
+
}
|
|
544
|
+
if (cur === null) buckets.changed.push({ path: f.path, reason: `超过 ${HARD_MAX_HASH_BYTES} 字节无法校验` })
|
|
545
|
+
else if (cur === f.sha256) buckets.unchanged.push({ path: f.path })
|
|
546
|
+
else buckets.changed.push({ path: f.path, reason: "内容已变", size: st.size })
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const e of extraList) if (!e.claimed) buckets.extra.push({ path: e.path, size: e.size })
|
|
550
|
+
|
|
551
|
+
const cap = (arr) => ({ total: arr.length, items: arr.slice(0, top) })
|
|
552
|
+
return {
|
|
553
|
+
checked_with_hash: hashed,
|
|
554
|
+
unchanged: cap(buckets.unchanged),
|
|
555
|
+
changed: cap(buckets.changed),
|
|
556
|
+
moved: cap(buckets.moved),
|
|
557
|
+
missing: cap(buckets.missing),
|
|
558
|
+
extra: cap(buckets.extra),
|
|
559
|
+
extra_truncated: extraTruncated,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function driftVerdict(drift) {
|
|
564
|
+
if (drift.changed.total > 0 || drift.missing.total > 0) {
|
|
565
|
+
return { verdict: "dirty", verdict_note: `缺失 ${drift.missing.total} 项、已变 ${drift.changed.total} 项,接续前先确认原因` }
|
|
566
|
+
}
|
|
567
|
+
if (drift.moved.total > 0) {
|
|
568
|
+
return { verdict: "renamed", verdict_note: `内容都在但 ${drift.moved.total} 项路径不同(改名或移动),接续时按新路径读` }
|
|
569
|
+
}
|
|
570
|
+
return { verdict: "clean", verdict_note: "清单文件全部一致,可直接接续" }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** 只替换状态文本里的来源根前缀,返回新 state 与命中明细 */
|
|
574
|
+
export function remapBundle(bundle, sourceRoot, targetRoot) {
|
|
575
|
+
const state = { goal: "", decisions: [], open_tasks: [], notes: "", ...(bundle.state || {}) }
|
|
576
|
+
let count = 0
|
|
577
|
+
const samples = []
|
|
578
|
+
const push = (text) => {
|
|
579
|
+
const r = remapText(text, sourceRoot, targetRoot)
|
|
580
|
+
count += r.count
|
|
581
|
+
if (r.count > 0 && samples.length < 5 && samples.indexOf(r.text) === -1) samples.push(r.text)
|
|
582
|
+
return r.text
|
|
583
|
+
}
|
|
584
|
+
const goal = push(state.goal)
|
|
585
|
+
const notes = push(state.notes)
|
|
586
|
+
const decisions = (state.decisions || []).map(push)
|
|
587
|
+
const openTasks = (state.open_tasks || []).map((t) => ({ ...t, text: push(t.text) }))
|
|
588
|
+
const hasJournal = Array.isArray(state.journal)
|
|
589
|
+
const journal = hasJournal ? state.journal.map((j) => ({ ...j, body: push(j.body) })) : []
|
|
590
|
+
const fields = {
|
|
591
|
+
goal: goal !== state.goal,
|
|
592
|
+
decisions: decisions.filter((d, i) => d !== (state.decisions || [])[i]).length,
|
|
593
|
+
open_tasks: openTasks.filter((t, i) => t.text !== (state.open_tasks || [])[i]?.text).length,
|
|
594
|
+
notes: notes !== state.notes,
|
|
595
|
+
journal: journal.filter((j, i) => j.body !== (hasJournal ? state.journal[i]?.body : undefined)).length,
|
|
596
|
+
}
|
|
597
|
+
const nextState = { ...state, goal, notes, decisions, open_tasks: openTasks }
|
|
598
|
+
if (hasJournal) nextState.journal = journal
|
|
599
|
+
else delete nextState.journal
|
|
600
|
+
return { state: nextState, count, samples, fields }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function migrateHandoff(args) {
|
|
604
|
+
const bundle = parseBundle(args.bundle)
|
|
605
|
+
const targetRoot = resolveRoot(args.target_root)
|
|
606
|
+
if (!fs.existsSync(targetRoot) || !fs.statSync(targetRoot).isDirectory()) {
|
|
607
|
+
throw new Error(`目标工作区不存在或不是目录:${targetRoot}`)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const strict = args.strict === undefined ? true : Boolean(args.strict)
|
|
611
|
+
const integrity = verifyIntegrity(bundle)
|
|
612
|
+
if (strict && !integrity.ok) {
|
|
613
|
+
const hint = integrity.expected ? `(期望 ${integrity.expected.slice(0, 12)}…,实际 ${integrity.actual.slice(0, 12)}…)` : ""
|
|
614
|
+
throw new Error(`bundle 完整性校验失败:${integrity.reason}${hint}。确认是手工编辑过的包可传 strict=false 放行`)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const sourceRoot = normSlashes(bundle.source.root)
|
|
618
|
+
const target = normSlashes(targetRoot)
|
|
619
|
+
const same = sameRoot(sourceRoot, target)
|
|
620
|
+
const remapped = same
|
|
621
|
+
? { state: { ...(bundle.state || {}) }, count: 0, samples: [], fields: {} }
|
|
622
|
+
: remapBundle(bundle, sourceRoot, target)
|
|
623
|
+
|
|
624
|
+
const doCheck = args.check === undefined ? true : Boolean(args.check)
|
|
625
|
+
const top = clampNumber(args.top, TOP_DEFAULT, 1, TOP_CAP, "top")
|
|
626
|
+
|
|
627
|
+
const result = {
|
|
628
|
+
schema: SCHEMA,
|
|
629
|
+
same_root: same,
|
|
630
|
+
source_root: sourceRoot,
|
|
631
|
+
target_root: target,
|
|
632
|
+
integrity,
|
|
633
|
+
path_rewrites: { count: remapped.count, samples: remapped.samples, fields: remapped.fields },
|
|
634
|
+
state: remapped.state,
|
|
635
|
+
summary: {
|
|
636
|
+
files_recorded: bundle.context.files.length,
|
|
637
|
+
open_tasks: (remapped.state.open_tasks || []).filter((t) => !t.done).length,
|
|
638
|
+
decisions: (remapped.state.decisions || []).length,
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (doCheck) {
|
|
643
|
+
const drift = detectDrift(bundle, targetRoot, top)
|
|
644
|
+
const v = driftVerdict(drift)
|
|
645
|
+
result.drift = drift
|
|
646
|
+
result.verdict = v.verdict
|
|
647
|
+
result.verdict_note = v.verdict_note
|
|
648
|
+
} else {
|
|
649
|
+
result.drift = null
|
|
650
|
+
result.verdict = "unchecked"
|
|
651
|
+
}
|
|
652
|
+
return result
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// HANDOFF.md 渲染
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
const fmtSize = (n) => (n >= 1048576 ? (n / 1048576).toFixed(2) + " MB" : n >= 1024 ? (n / 1024).toFixed(1) + " KB" : n + " B")
|
|
660
|
+
|
|
661
|
+
export function renderBrief(b, extra = {}) {
|
|
662
|
+
const st = b.state || {}
|
|
663
|
+
const L = []
|
|
664
|
+
L.push(`# 工作区接续包 · ${extra.title_label || b.source.label || b.source.root}`)
|
|
665
|
+
L.push("")
|
|
666
|
+
L.push(`- 来源工作区:\`${extra.show_source_root === false ? "(已迁移,见 handoff.json 的 migrated.from_root)" : b.source.root}\``)
|
|
667
|
+
L.push(`- 生成时间:${b.created_at}`)
|
|
668
|
+
L.push(`- 产出插件:${b.producer.plugin} v${b.producer.version}(${b.schema})`)
|
|
669
|
+
if (extra.target_root) L.push(`- 目标工作区:\`${extra.target_root}\`${extra.rewrites ? `(重映射 ${extra.rewrites} 处路径)` : ""}`)
|
|
670
|
+
if (extra.verdict) L.push(`- 接续体检:${extra.verdict}${extra.verdict_note ? " — " + extra.verdict_note : ""}`)
|
|
671
|
+
L.push("")
|
|
672
|
+
L.push("## 目标")
|
|
673
|
+
L.push("")
|
|
674
|
+
L.push(st.goal || "(未填写)")
|
|
675
|
+
L.push("")
|
|
676
|
+
if ((st.decisions || []).length > 0) {
|
|
677
|
+
L.push("## 关键决策")
|
|
678
|
+
L.push("")
|
|
679
|
+
for (const d of st.decisions) L.push(`- ${d}`)
|
|
680
|
+
L.push("")
|
|
681
|
+
}
|
|
682
|
+
const tasks = st.open_tasks || []
|
|
683
|
+
if (tasks.length > 0) {
|
|
684
|
+
L.push("## 任务清单")
|
|
685
|
+
L.push("")
|
|
686
|
+
for (const t of tasks) L.push(`- [${t.done ? "x" : " "}] ${t.text}`)
|
|
687
|
+
L.push("")
|
|
688
|
+
L.push(`(未完成 ${tasks.filter((t) => !t.done).length} / 共 ${tasks.length})`)
|
|
689
|
+
L.push("")
|
|
690
|
+
}
|
|
691
|
+
const files = b.context.files || []
|
|
692
|
+
if (files.length > 0) {
|
|
693
|
+
L.push("## 上下文文件清单")
|
|
694
|
+
L.push("")
|
|
695
|
+
L.push("| 路径 | 大小 | 指纹(前 12) |")
|
|
696
|
+
L.push("|---|---|---|")
|
|
697
|
+
for (const f of files) L.push(`| \`${f.path}\` | ${fmtSize(f.size)} | ${f.sha256 ? "`" + f.sha256.slice(0, 12) + "`" : "—"} |`)
|
|
698
|
+
L.push("")
|
|
699
|
+
const s = b.context.stats || {}
|
|
700
|
+
L.push(
|
|
701
|
+
`共 ${files.length} 项` +
|
|
702
|
+
(s.scanned ? `(本轮扫描 ${s.scanned} 个文件` : "(") +
|
|
703
|
+
(s.skipped_large ? `${s.scanned ? "," : ""}${s.skipped_large} 项过大未取哈希` : "") +
|
|
704
|
+
")。"
|
|
705
|
+
)
|
|
706
|
+
L.push("")
|
|
707
|
+
}
|
|
708
|
+
const d = extra.drift
|
|
709
|
+
if (d) {
|
|
710
|
+
const row = (label, arr) => {
|
|
711
|
+
if (arr.total === 0) return `| ${label} | 0 | |`
|
|
712
|
+
const shown = arr.items.map((x) => `\`${x.path || x.from || "?"}\``).join("、")
|
|
713
|
+
const more = arr.total > arr.items.length ? ` 等共 ${arr.total} 项` : ""
|
|
714
|
+
return `| ${label} | ${arr.total} | ${shown}${more} |`
|
|
715
|
+
}
|
|
716
|
+
L.push("## 目标工作区漂移")
|
|
717
|
+
L.push("")
|
|
718
|
+
L.push("| 状态 | 数量 | 示例 |")
|
|
719
|
+
L.push("|---|---|---|")
|
|
720
|
+
L.push(row("未变", d.unchanged))
|
|
721
|
+
L.push(row("已改", d.changed))
|
|
722
|
+
L.push(row("改名", d.moved))
|
|
723
|
+
L.push(row("缺失", d.missing))
|
|
724
|
+
L.push(row("新增", d.extra))
|
|
725
|
+
L.push("")
|
|
726
|
+
L.push(`其中 ${d.checked_with_hash} 项按 sha256 精确校验。`)
|
|
727
|
+
L.push("")
|
|
728
|
+
}
|
|
729
|
+
if ((st.journal || []).length > 0) {
|
|
730
|
+
L.push("## 近期日志(.journal)")
|
|
731
|
+
L.push("")
|
|
732
|
+
for (const j of st.journal) L.push(`- ${j.time}${j.tags && j.tags.length ? ` [${j.tags.join(",")}]` : ""}:${j.body.replace(/\s+/g, " ")}`)
|
|
733
|
+
L.push("")
|
|
734
|
+
}
|
|
735
|
+
if (st.notes) {
|
|
736
|
+
L.push("## 备注")
|
|
737
|
+
L.push("")
|
|
738
|
+
L.push(st.notes)
|
|
739
|
+
L.push("")
|
|
740
|
+
}
|
|
741
|
+
L.push("---")
|
|
742
|
+
L.push("")
|
|
743
|
+
L.push("本文件由 dsh-workspace-handoff 生成;在新工作区里让模型先读 HANDOFF.md 即可接续工作。")
|
|
744
|
+
return L.join("\n")
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// apply_handoff
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
export function applyHandoff(args) {
|
|
752
|
+
const bundle = parseBundle(args.bundle)
|
|
753
|
+
const targetRoot = resolveRoot(args.target_root)
|
|
754
|
+
if (!fs.existsSync(targetRoot) || !fs.statSync(targetRoot).isDirectory()) {
|
|
755
|
+
throw new Error(`目标工作区不存在或不是目录:${targetRoot}`)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const strict = args.strict === undefined ? true : Boolean(args.strict)
|
|
759
|
+
const integrity = verifyIntegrity(bundle)
|
|
760
|
+
if (!integrity.ok && strict && !args.force) {
|
|
761
|
+
throw new Error(`bundle 完整性校验失败:${integrity.reason}。确认无误可传 force=true 强制落地`)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const mode = args.mode === undefined ? "overwrite" : String(args.mode)
|
|
765
|
+
if (!["overwrite", "skip", "versioned"].includes(mode)) throw new Error(`mode 只能是 overwrite / skip / versioned(收到 ${mode})`)
|
|
766
|
+
|
|
767
|
+
const outDirRaw = args.out_dir === undefined ? ".handoff" : String(args.out_dir)
|
|
768
|
+
if (path.isAbsolute(outDirRaw) || normSlashes(outDirRaw).split("/").includes("..")) {
|
|
769
|
+
throw new Error(`out_dir 必须为目标工作区内的相对路径(收到 ${outDirRaw})`)
|
|
770
|
+
}
|
|
771
|
+
const outDir = path.resolve(targetRoot, outDirRaw.split("/").join(path.sep))
|
|
772
|
+
if (!isWithin(targetRoot, outDir)) throw new Error("out_dir 越出目标工作区范围")
|
|
773
|
+
|
|
774
|
+
const dryRun = args.dry_run === undefined ? false : Boolean(args.dry_run)
|
|
775
|
+
const sourceRoot = normSlashes(bundle.source.root)
|
|
776
|
+
const target = normSlashes(targetRoot)
|
|
777
|
+
const same = sameRoot(sourceRoot, target)
|
|
778
|
+
const remapped = same
|
|
779
|
+
? { state: { ...(bundle.state || {}) }, count: 0, samples: [] }
|
|
780
|
+
: remapBundle(bundle, sourceRoot, target)
|
|
781
|
+
|
|
782
|
+
const doCheck = args.check === undefined ? true : Boolean(args.check)
|
|
783
|
+
const top = clampNumber(args.top, TOP_DEFAULT, 1, TOP_CAP, "top")
|
|
784
|
+
const drift = doCheck ? detectDrift(bundle, targetRoot, top) : null
|
|
785
|
+
const v = drift ? driftVerdict(drift) : {}
|
|
786
|
+
|
|
787
|
+
const mdText = renderBrief({ ...bundle, source: { ...bundle.source, root: target }, state: remapped.state }, {
|
|
788
|
+
title_label: path.basename(targetRoot),
|
|
789
|
+
show_source_root: same,
|
|
790
|
+
target_root: target,
|
|
791
|
+
rewrites: remapped.count,
|
|
792
|
+
verdict: drift ? v.verdict : undefined,
|
|
793
|
+
verdict_note: drift ? v.verdict_note : undefined,
|
|
794
|
+
drift,
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
const written = {
|
|
798
|
+
...bundle,
|
|
799
|
+
source: { ...bundle.source, root: target, label: path.basename(targetRoot) },
|
|
800
|
+
state: remapped.state,
|
|
801
|
+
migrated: {
|
|
802
|
+
from_root: sourceRoot,
|
|
803
|
+
to_root: target,
|
|
804
|
+
at: new Date().toISOString(),
|
|
805
|
+
path_rewrites: remapped.count,
|
|
806
|
+
same_root: same,
|
|
807
|
+
integrity_ok: integrity.ok,
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
delete written.integrity
|
|
811
|
+
written.integrity = { algo: "sha256-canonical-v1", digest: sha256Text(canonicalize(written)) }
|
|
812
|
+
|
|
813
|
+
const planned = [
|
|
814
|
+
{ file: "HANDOFF.md", text: mdText },
|
|
815
|
+
{ file: "handoff.json", text: JSON.stringify(written, null, 2) + "\n" },
|
|
816
|
+
]
|
|
817
|
+
|
|
818
|
+
const actions = []
|
|
819
|
+
for (const p of planned) {
|
|
820
|
+
let abs = path.join(outDir, p.file)
|
|
821
|
+
const exists = fs.existsSync(abs)
|
|
822
|
+
if (exists && mode === "skip") {
|
|
823
|
+
actions.push({ file: p.file, action: "skipped", path: abs })
|
|
824
|
+
continue
|
|
825
|
+
}
|
|
826
|
+
if (exists && mode === "versioned") {
|
|
827
|
+
const ext = path.extname(p.file)
|
|
828
|
+
const base = path.basename(p.file, ext)
|
|
829
|
+
let n = 2
|
|
830
|
+
while (fs.existsSync(path.join(outDir, `${base}-v${n}${ext}`))) n++
|
|
831
|
+
abs = path.join(outDir, `${base}-v${n}${ext}`)
|
|
832
|
+
actions.push({ file: p.file, action: dryRun ? "planned_versioned" : "written_versioned", path: abs, bytes: Buffer.byteLength(p.text, "utf8") })
|
|
833
|
+
if (!dryRun) {
|
|
834
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
835
|
+
fs.writeFileSync(abs, p.text, "utf8")
|
|
836
|
+
}
|
|
837
|
+
continue
|
|
838
|
+
}
|
|
839
|
+
actions.push({
|
|
840
|
+
file: p.file,
|
|
841
|
+
action: dryRun ? (exists ? "planned_overwrite" : "planned_create") : exists ? "overwritten" : "created",
|
|
842
|
+
path: abs,
|
|
843
|
+
bytes: Buffer.byteLength(p.text, "utf8"),
|
|
844
|
+
})
|
|
845
|
+
if (!dryRun) {
|
|
846
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
847
|
+
fs.writeFileSync(abs, p.text, "utf8")
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const landed = actions.filter((a) => ["written_versioned", "created", "overwritten"].includes(a.action)).length
|
|
852
|
+
return {
|
|
853
|
+
schema: SCHEMA,
|
|
854
|
+
dry_run: dryRun,
|
|
855
|
+
out_dir: outDirRaw,
|
|
856
|
+
same_root: same,
|
|
857
|
+
integrity,
|
|
858
|
+
path_rewrites: remapped.count,
|
|
859
|
+
verdict: drift ? v.verdict : "unchecked",
|
|
860
|
+
drift,
|
|
861
|
+
actions,
|
|
862
|
+
hint: dryRun
|
|
863
|
+
? "dry_run=true 只给出计划,未写盘;确认后去掉 dry_run 即落地"
|
|
864
|
+
: `已落地 ${landed} 个文件;读取 ${outDirRaw}/HANDOFF.md 即可接续(本插件不删除任何既有文件)`,
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
// 工具定义
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
const packHandoffTool = defineTool({
|
|
873
|
+
name: "pack_handoff",
|
|
874
|
+
description:
|
|
875
|
+
"把当前工作区的一次工作状态打包成可迁移的 handoff 包(JSON):目标、关键决策、未完成任务、备注," +
|
|
876
|
+
"外加按 glob 展开的上下文文件清单(相对路径 + 大小 + mtime + sha256 指纹)。" +
|
|
877
|
+
"清单只记相对路径,因此换机器、换盘符、换目录名都能解;可选把 dsh-workspace-journal 的 .journal 近期条目一并带上。" +
|
|
878
|
+
"包内含对规范化 JSON 取的 sha256 完整性摘要,供迁移与落地时校验。",
|
|
879
|
+
parameters: {
|
|
880
|
+
goal: { type: "string", description: "必填,要接续的目标(一句话说清接下来干什么,≤500 字符)" },
|
|
881
|
+
decisions: { type: "array", description: "可选,已达成的关键决策字符串数组(含口径、约束等,迁移后会被读回)" },
|
|
882
|
+
open_tasks: { type: "array", description: "可选,任务数组:元素可为字符串,或 {text, done} 对象" },
|
|
883
|
+
notes: { type: "string", description: "可选,补充备注(≤4000 字符;其中的绝对路径会在迁移时自动重映射)" },
|
|
884
|
+
files: { type: "array", description: "可选,上下文文件 glob 数组,如 [\"**/*.md\",\"src/**/*.ts\"];不含通配符的项按直接相对路径处理" },
|
|
885
|
+
root: { type: "string", description: "可选,工作区根目录(默认当前工作目录)" },
|
|
886
|
+
label: { type: "string", description: "可选,工作区名称标注(默认取根目录名)" },
|
|
887
|
+
hash: { type: "boolean", description: "可选,是否对清单文件取 sha256(默认 true)" },
|
|
888
|
+
max_files: { type: "number", description: "可选,匹配文件数上限(1~20000,默认 2000)" },
|
|
889
|
+
max_hash_bytes: { type: "number", description: "可选,单文件取哈希的字节上限(默认 4194304)" },
|
|
890
|
+
prune: { type: "array", description: "可选,遍历剪枝目录的相对路径数组(默认 .git/node_modules/.handoff/.journal/dist/build/__pycache__)" },
|
|
891
|
+
include_journal: { type: "boolean", description: "可选,是否带上 .journal 近期日志条目(默认 false)" },
|
|
892
|
+
journal_limit: { type: "number", description: "可选,日志条目上限(1~100,默认 10)" },
|
|
893
|
+
},
|
|
894
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
895
|
+
async execute(args) {
|
|
896
|
+
if (!args || args.goal === undefined) throw new Error("缺少必填参数 goal")
|
|
897
|
+
return JSON.stringify(packHandoff(args), null, 2)
|
|
898
|
+
},
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
const migrateHandoffTool = defineTool({
|
|
902
|
+
name: "migrate_handoff",
|
|
903
|
+
description:
|
|
904
|
+
"把 handoff 包迁移到另一个工作区根:状态文本里的绝对路径按「来源根 → 目标根」重映射(容忍斜杠与反斜杠混用、" +
|
|
905
|
+
"Windows 盘符大小写差异),并按目标根校验清单文件产出漂移报告(未变 / 已改 / 改名 / 缺失 / 新增)与接续结论" +
|
|
906
|
+
"(clean / renamed / dirty)。只读盘不落盘,落地请用 apply_handoff。",
|
|
907
|
+
parameters: {
|
|
908
|
+
bundle: { type: "string", description: "必填,pack_handoff 产出的 JSON 字符串,或 bundle 文件路径(如 .handoff/handoff.json)" },
|
|
909
|
+
target_root: { type: "string", description: "必填,目标工作区根目录(绝对路径或相对当前目录)" },
|
|
910
|
+
check: { type: "boolean", description: "可选,是否执行漂移检测(默认 true)" },
|
|
911
|
+
strict: { type: "boolean", description: "可选,完整性校验失败时是否直接报错(默认 true)" },
|
|
912
|
+
top: { type: "number", description: "可选,各类漂移明细最多列出条数(1~500,默认 50)" },
|
|
913
|
+
},
|
|
914
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
915
|
+
async execute(args) {
|
|
916
|
+
if (!args || args.bundle === undefined) throw new Error("缺少必填参数 bundle")
|
|
917
|
+
if (!args || args.target_root === undefined) throw new Error("缺少必填参数 target_root")
|
|
918
|
+
return JSON.stringify(migrateHandoff(args), null, 2)
|
|
919
|
+
},
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
const applyHandoffTool = defineTool({
|
|
923
|
+
name: "apply_handoff",
|
|
924
|
+
description:
|
|
925
|
+
"在目标工作区落地接续产物:out_dir/HANDOFF.md(人类可读接续简报,含重映射后的路径、任务复选框、文件清单与漂移表)" +
|
|
926
|
+
"与 out_dir/handoff.json(迁移后重算完整性的机器包)。同名冲突策略 overwrite / skip / versioned,支持 dry_run 只看计划;" +
|
|
927
|
+
"任何情况下不删除、不改动清单之外的既有文件。",
|
|
928
|
+
parameters: {
|
|
929
|
+
bundle: { type: "string", description: "必填,handoff 包 JSON 字符串或文件路径" },
|
|
930
|
+
target_root: { type: "string", description: "必填,目标工作区根目录" },
|
|
931
|
+
mode: { type: "string", description: "可选,同名文件冲突策略:overwrite(默认)/ skip / versioned(追加 -v2、-v3)" },
|
|
932
|
+
out_dir: { type: "string", description: "可选,落地子目录(目标工作区内相对路径,默认 .handoff)" },
|
|
933
|
+
dry_run: { type: "boolean", description: "可选,只返回写入计划不落盘(默认 false)" },
|
|
934
|
+
check: { type: "boolean", description: "可选,是否执行漂移检测并写入简报(默认 true)" },
|
|
935
|
+
strict: { type: "boolean", description: "可选,完整性校验失败时拒绝写入(默认 true)" },
|
|
936
|
+
force: { type: "boolean", description: "可选,配合 strict=false:手工编辑过的包强制落地" },
|
|
937
|
+
top: { type: "number", description: "可选,漂移明细最多列出条数(默认 50)" },
|
|
938
|
+
},
|
|
939
|
+
output: { schema: { type: "string" }, render: (_a, v) => [{ type: "text", text: v }] },
|
|
940
|
+
async execute(args) {
|
|
941
|
+
if (!args || args.bundle === undefined) throw new Error("缺少必填参数 bundle")
|
|
942
|
+
if (!args || args.target_root === undefined) throw new Error("缺少必填参数 target_root")
|
|
943
|
+
return JSON.stringify(applyHandoff(args), null, 2)
|
|
944
|
+
},
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// 插件入口
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
export function apply(ctx) {
|
|
952
|
+
ctx.tools.register(packHandoffTool)
|
|
953
|
+
ctx.tools.register(migrateHandoffTool)
|
|
954
|
+
ctx.tools.register(applyHandoffTool)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export { packHandoffTool, migrateHandoffTool, applyHandoffTool }
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-handoff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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), and apply it as HANDOFF.md + handoff.json with overwrite/skip/versioned conflict modes and dry-run. Zero dependencies, Node built-ins only, never deletes files.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dsh",
|
|
14
|
+
"plugin",
|
|
15
|
+
"workspace",
|
|
16
|
+
"handoff",
|
|
17
|
+
"session",
|
|
18
|
+
"migrate",
|
|
19
|
+
"context",
|
|
20
|
+
"manifest",
|
|
21
|
+
"sha256",
|
|
22
|
+
"drift"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.2"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node test-plugin.js",
|
|
31
|
+
"prepublishOnly": "npm test"
|
|
32
|
+
},
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"private": false,
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"registry": "https://registry.npmjs.org/",
|
|
41
|
+
"access": "public",
|
|
42
|
+
"tag": "latest"
|
|
43
|
+
}
|
|
44
|
+
}
|