dsh-report-ledger 0.2.0 → 0.2.2

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 CHANGED
@@ -31,8 +31,8 @@ dsh plugin --profile web remove dsh-report-ledger
31
31
 
32
32
  ```sh
33
33
  # 1) 下载发行版附件(预构建产物,不需要构建工具链)
34
- # https://gitee.com/stone_zhan/dsh-report-ledger/releases/download/v0.2.0/dsh-report-ledger-0.2.0.tgz
35
- dsh plugin --profile web add ./dsh-report-ledger-0.2.0.tgz
34
+ # https://gitee.com/stone_zhan/dsh-report-ledger/releases/download/v0.2.2/dsh-report-ledger-0.2.2.tgz
35
+ dsh plugin --profile web add ./dsh-report-ledger-0.2.2.tgz
36
36
 
37
37
  # 2) 或直接从 Gitee 安装(安装时自动构建,约 15 秒)
38
38
  dsh plugin --profile web add git+https://gitee.com/stone_zhan/dsh-report-ledger.git
@@ -44,8 +44,8 @@ GitHub 每次推送后由 `.github/workflows/mirror-to-gitee.yml` 自动镜像 `
44
44
 
45
45
  ### 装完你会得到什么
46
46
 
47
- - **宿主半**(任何 profile):十二个 `report_*` 工具 + `peer_list` / `peer_start`、
48
- 两段 `systemPrompt` 段,以及 web profile 下的两个只读 HTTP 端点;
47
+ - **宿主半**(任何 profile):十一个 `report_*` 工具 + `peer_list` / `peer_start`(开启工作区后是十二个;`report_lease` 默认不存在)、
48
+ 两段 `systemPrompt` 段,以及 web profile 下的三个只读 HTTP 端点;
49
49
  工作区功能默认关闭,因此 `report_lease` 与各工具的 `worktree` 参数默认**不出现**;
50
50
  - **浏览器半**(仅 web profile):会话头部多一个「汇报」标签页 —— 时间线、状态芯片、
51
51
  搜索、线程跳转与就地展开的传递路径;若开启了工作区,报告行上另有检出标记。
@@ -53,19 +53,21 @@ GitHub 每次推送后由 `.github/workflows/mirror-to-gitee.yml` 自动镜像 `
53
53
  ### 兼容性、权限与数据
54
54
 
55
55
  - **DSH 0.2.0-rc.2 对齐**(构建产物与该版本的平台模块表逐条比对一致;类型检查、构建与
56
- 82 条断言均通过)。0.1.5-rc.3 已不再支持:那个 shell 的图标导出带画板尺寸后缀(如
56
+ 全部确定性断言均通过,具体套数与数量见「开发与验证」)。0.1.5-rc.3 已不再支持:那个 shell 的图标导出带画板尺寸后缀(如
57
57
  `IconCopyOutline16`),0.2.0 改成尺寸中立的 `…Regular` / `…Medium` 成对导出,尺寸交给
58
58
  `size` prop。宿主半的运行时
59
- 外部依赖只有一个:`@deepseek-ai/dsh-tools`,由 profile 提供;浏览器半 require
59
+ 外部依赖有三条:`@deepseek-ai/dsh-tools` 与 `@deepseek-ai/dsh-workspace`(都由 profile 提供,**不声明**),
60
+ 以及 `yaml`(声明在 `dependencies` 里,由包管理器安装;front matter 的 YAML 编解码用它);
61
+ `lib/` 随源码提交,固定 Commit 里就带着可运行的构建产物。浏览器半 require
60
62
  `react`、`react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives`(面板里的
61
63
  按钮、芯片、悬停、状态点、输入框与正文渲染全部用它,与内置的 Chat / 轨迹视图同一套),
62
- 其余全部内联。宿主半在 headless profile 里同样可用(没有 web server 时只是少了那两个端点)。
64
+ 其余全部内联。宿主半在 headless profile 里同样可用(没有 web server 时只是少了那三个端点)。
63
65
  ⚠️ 于是浏览器半多了一条硬依赖:**shell 的冻结模块表里必须有
64
66
  `dsh-client-ui-primitives`**。它自带的对话视图就依赖它,所以缺了它意味着那个 shell
65
67
  本身已经坏了;真缺的话失败模式是「汇报标签页不出现」而不是整个 GUI 崩。
66
68
  - **账本是本机文件**:`$DSH_HOME/report-ledger/`(可用 `DSH_REPORT_LEDGER_ROOT`
67
69
  覆盖),不上传任何地方。
68
- - **浏览器读数据走两个 GET 路由**,注册在 DSH 的 web server 上,守卫仅放行 loopback
70
+ - **浏览器读数据走三个 GET 路由**,注册在 DSH 的 web server 上,守卫仅放行 loopback
69
71
  对端与 loopback `Host`;细节与信任假设见下文「守卫与信任假设」。
70
72
  - **同伴工具会新建根会话**(在你自己的工作目录下),这是本插件唯一会新增活代理的能力,
71
73
  每次创建都记进名册日志,并受 `maxPeersPerAgent`(默认 8)约束。
@@ -107,13 +109,13 @@ DSH 原本的代理间通信是**相邻 Agent 的 steer 投递**(`ctx.subagent
107
109
  **传递路径是权威的。** 每次操作都追加一跳(append-only JSONL),因此:
108
110
 
109
111
  - `to` / `cc` / `authors` / `hops` / `last` 都是**从跳流派生**的缓存,任何下一条跳都会重新校正它——账本不可能显示历史里没有的收件人;
110
- - **待投递集合也由跳流派生**:被投递过(`sent`/`cc`/`forwarded`/`copied`)但没有到达(`delivered`)的收件人即为待投递。**审计轨迹本身就是那个缺失的持久 mailbox**,所以它天然跨重启存活,且不可能与历史不一致。
112
+ - **待投递集合也由跳流派生**:被投递过(`sent`/`cc`/`forwarded`/`copied`)且其后还没有新的到达(`delivered`)的收件人即为待投递——**一次到达只销掉它之前的那次投递**,之后再次主送会重新排队。**唤醒策略按窗口**:窗口内只要还有一次未答复的主送就 `steer` 唤醒,只有纯抄送时才 `inject`。**审计轨迹本身就是那个缺失的持久 mailbox**,所以它天然跨重启存活,且不可能与历史不一致。
111
113
 
112
114
  **投递走公开的 Agent 入口。** 模型工具受"精确相邻"约束,但 `Agent.steer/inject` 只接收一条消息、不做授权检查,而任何活 Agent 都可由 `ctx.agents.get(id)` 取到。因此:
113
115
 
114
116
  - 主送(`to`)用 `steer`——空闲目标会因此开启一个回合;
115
117
  - 抄送(`cc`)用 `inject`——进入上下文但不唤醒任何人;
116
- - **非驻留收件人不会被拒绝**,而是留在待投递集合里,等 `agent/created` 事件到来时自动送达。
118
+ - **非驻留收件人不会被拒绝**,而是留在待投递集合里,等 `agent/created` 事件到来时自动送达;插件加载/重载时还会对当时已驻留的会话补投一次——这些会话不会再收到 `agent/created`,否则那份交接要一直等到下次 resume。
117
119
 
118
120
  ## 账本布局
119
121
 
@@ -124,7 +126,9 @@ reports/R-0001.md front matter 缩略 + 正文
124
126
  reports/R-0001.route.jsonl append-only 传递路径,一行一跳
125
127
  ```
126
128
 
127
- 选择文件而非私有数据库,是因为账本是一段协作的长期记忆:它必须可被人直接检查、手工修正(写错主题、补一句 hop 说明都不需要迁移),而 front matter 让"只读缩略"不必打开正文。
129
+ 写文档走 temp + rename(Windows 的共享冲突做短重试,失败会清掉 temp);进程被强杀可能留下 `R-0001.md.<hex>.tmp`,目录扫描会忽略它。
130
+
131
+ 选择文件而非私有数据库,是因为账本是一段协作的长期记忆:它必须可被人直接检查、手工修正(写错主题、补一句 hop 说明都不需要迁移;Windows 的 CRLF 与 BOM 也能读),而 front matter 让"只读缩略"不必打开正文。
128
132
 
129
133
  front matter 里唯一和工作区有关的字段是**可选的** `worktree` 块(`path` / `branch` / `head` / `repo` / `allocated`);它缺席是常态,老账本不需要任何迁移。跳动作词表里对应三个:`leased`、`released`、`reclaimed`——一个被 `parseHop` 丢弃的动作就是一段无声消失的审计,所以词表是**单一来源**(类型与运行期校验都从同一个常量派生,加动作时漏改一处会被编译器逼出来)。
130
134
 
@@ -137,13 +141,13 @@ front matter 里唯一和工作区有关的字段是**可选的** `worktree` 块
137
141
  | `report_send` | 主送给更多代理(唤醒):向上回报、向下派发 |
138
142
  | `report_cc` | 抄送给更多代理(不唤醒):让需要知情者持有副本 |
139
143
  | `report_forward` | 转呈下去,或 `mode:"copy"` 作为参考副本分发 |
140
- | `report_read` | 读缩略 + 完整传递路径 + 正文(读取本身也记一跳) |
144
+ | `report_read` | 读缩略 + 完整传递路径 + 正文(读取本身也记一跳;**读取不推进 `updated`、不改变列表顺序**) |
141
145
  | `report_list` | 只列缩略,可按 `session`、`status`、`task`、`worktree` 过滤,用于纵览长期协作 |
142
146
  | `report_ack` | 回执,让发送方看到闭环 |
143
147
  | `report_amend` | 修正:改缩略字段或更正正文,仅发起者/共写者可改,改动会被记录 |
144
148
  | `report_close` | 结案:事情的终结,仅发起者/共写者可关;同时**交还**它持有的工作区 |
145
149
  | `report_lease` | 认领一个**已存在**的检出(仅工作区功能开启时暴露) |
146
- | `report_release` | 交还检出占用,检出留在磁盘上原封不动 |
150
+ | `report_release` | 交还检出占用,检出留在磁盘上原封不动(工作区关闭时调用会说明"本部署未启用工作区") |
147
151
 
148
152
  ## 修正:记录,而不是抹掉
149
153
 
@@ -153,7 +157,7 @@ front matter 里唯一和工作区有关的字段是**可选的** `worktree` 块
153
157
 
154
158
  | 对象 | 语义 | 理由 |
155
159
  |---|---|---|
156
- | 缩略字段(`subject` / `task` / `artifacts`) | **替换**,跳里逐字记录改前改后的值 | 它们是**当前状态**,不是历史;而"从什么改成了什么"正是审计要的 |
160
+ | 缩略字段(`subject` / `task` / `artifacts`) | **替换**;`subject`/`task` 在跳里逐字记录改前改后,`artifacts` 记录数量变化(如 `2 -> 3`) | 它们是**当前状态**,不是历史;而"从什么改成了什么"正是审计要的 |
157
161
  | 正文 | 默认**追加**一段 `### amendment` | 正文记录的是**人说过的话**,改写别人的话就不是记录了 |
158
162
  | 正文(`replace_body: true`) | 真替换,且跳里注明"body replaced" | 有时确实需要重写;那就**让后人知道被重写过、被谁重写**,而不是被误导 |
159
163
 
@@ -174,7 +178,7 @@ front matter: subject: "the corrected title" ← 当前真相
174
178
 
175
179
  | 动作 | 谁能做 | 效果 |
176
180
  |---|---|---|
177
- | `report_ack` | 任何收件人 | 记一跳;`open` → `acked`。**回执 = "我收到了"** |
181
+ | `report_ack` | 任何收件人(`to`/`cc`/共写者/发起者);非收件人会被拒绝并列出可回执的人 | 记一跳;`open` → `acked`。**回执 = "我收到了"** |
178
182
  | `report_close` | **仅发起者或共写者** | 记一跳;→ `closed`。**结案 = "这件事结束了"** |
179
183
  | 贡献 / 主送 / 抄送 / 转呈 | 任何有权限者 | **自动重开**:先记 `reopened` 跳,`closed` → `open`,随后才落下活动本身那一跳 |
180
184
 
@@ -242,7 +246,7 @@ report_release 交还占用;检出原封不动留在磁盘上,结束的只
242
246
  report_close 结案 = 自动交还,并按配置决定磁盘处置
243
247
  ```
244
248
 
245
- `report_lease` 的 `branch` 可以省略:**分支从检出本身读出**,所以存进账本的是 git 报的真实分支与真实 commit,而不是调用方声称的。只有想强制校验时才传它。
249
+ `report_lease` 的 `branch` 可以省略:**分支从检出本身读出**,所以存进账本的是 git 报的真实分支与真实 commit,而不是调用方声称的。只有想强制校验时才传它。`path` 必须是**绝对路径**:相对路径在不同进程里指不同目录,所以会被直接拒绝,而不是按当前进程的 cwd 解释。
246
250
 
247
251
  **没有"接管"策略**:缺席的持有者不会被抢走检出。它必须自己交还或结案——一个可能只是慢的代理,不该因为"没回应"就丢掉工作。被拒绝时会点名持有者**和它的作者们**。
248
252
 
@@ -251,7 +255,7 @@ report_close 结案 = 自动交还,并按配置决定磁盘处置
251
255
  | 配置 | 量什么 | 满了会怎样 |
252
256
  |---|---|---|
253
257
  | `maxActive`(默认 4) | 同时被**占用**的检出 | **硬拒绝**,并点名占用者、以及它此刻是否在线 |
254
- | `maxRetained`(默认 4) | 结案后仍留在磁盘上的检出 | 只有开了 `reclaim` 才清;**脏的一律跳过并报出** |
258
+ | `maxRetained`(默认 4) | 结案后仍留在磁盘上的检出 | 只有开了 `reclaim` 才清;**脏的一律跳过并报出**。维护视图的 `retained` 比分配的候选集宽:它含报告仍开着、以及人手采纳的检出(后者永远不会被回收);分配数的是真正可能被清掉的那些 |
255
259
 
256
260
  **回收永远不能腾出活跃名额**——可回收的检出按定义就是"占用已经结束"的那些。把两者耦合成一个开关,等于把"把检出从正在干活的代理手里拿走"做成一个配置项。
257
261
 
@@ -260,6 +264,7 @@ report_close 结案 = 自动交还,并按配置决定磁盘处置
260
264
  - **适配器里没有 `--force`。** 没有任何调用方能覆盖 git 的保障:脏检出删不掉、被别人占着的分支抢不走。
261
265
  - **结案默认不动磁盘**(`onClose: 'keep'`)。开了 `remove-if-clean` 也只删干净的;被拒绝时**原样抛出 git 的话**,而不是吞掉。
262
266
  - **删除的授权来自报告**:只有 `allocated: true` 的才可能被回收,人手建的目录账本永不碰。
267
+ - **回收不会动别人正在用的检出。** 候选先与"任何活着的占用"逐路径比对:若另一个汇报已经采纳了它,候选被跳过并在输出里点名持有者。路径相同就是同一个检出——与两者各自记录的分支无关,因为换分支只是换状态,不是换目录。
263
268
  - **人手删掉的检出会被一次性发现**:下次分配时账本记一笔"已经不在磁盘上",此后不再重复检查——而不是每次重新审视、并被误报成"有未提交改动"。
264
269
  - **回收是终态**:`reclaimed` 与 `released` 是两个状态。账本清掉一个检出——无论是超预算回收,还是结案时按 `remove-if-clean` 移除——都会记下 `reclaimed`;此后它不再被当成候选,也不会被误报成"异常消失"。这一条不是洁癖:一次**故意**的移除如果读起来像异常,那条警告就会在推荐流程上每次都响,而读者会学会忽略它。
265
270
 
@@ -272,11 +277,11 @@ worktree:
272
277
  onClose: keep # 结案处置:keep(默认)| remove-if-clean
273
278
  maxActive: 4 # 同时被占用的上限
274
279
  maxRetained: 4 # 结案后留在磁盘上的上限
275
- reclaim: never # never(默认)| oldest-closed:超限时清最老的、干净的、账本自建的
280
+ reclaim: never # never(默认)| oldest-closed:超限时清一个最老的、干净的、账本自建的(每次分配最多一个)
276
281
  relativePaths: false # 用相对路径写 gitfile
277
282
  ```
278
283
 
279
- `relativePaths` 不是纯外观:它启用 `extensions.relativeWorktrees`,把 `core.repositoryformatversion` 抬到 1,**旧版 git 会直接拒绝访问该仓库**,所以默认关闭。
284
+ `relativePaths` 不是纯外观:它启用 `extensions.relativeWorktrees`,把 `core.repositoryformatversion` 抬到 1,**旧版 git 会直接拒绝访问该仓库**,所以默认关闭。分配前还会先问 `git --version`:低于 2.48 时直接拒绝并说明,而不是让每次 `git worktree add` 死在 `unknown option 'relative-paths'` 上。
280
285
 
281
286
  **子模块是硬闸门**:仓库里有 `.gitmodules` 时分配被拒绝。git 自己的手册说多检出仍属实验性、子模块支持不完整,且含子模块的链接工作区**连 `move` 都不行**——这里选择拒绝而不是提醒。
282
287
 
@@ -293,7 +298,7 @@ worktree:
293
298
  | 协议 | `plugin:report-ledger` / 200 | 静态字符串 | 对所有 agent **逐字节相同**,因此是各 scope 里稳定的提示词前缀(KV cache 友好) |
294
299
  | 伙伴契约 | `plugin:report-ledger:partnership` / 201 | 按 assembly 求值的函数 | 契约要对两种读者说不同的话 |
295
300
 
296
- **协议段**说明账本是什么、各工具做什么(十四个;工作区功能关闭时不暴露 `report_lease`),以及会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成;工作区若启用,独立的是哪些、共享的是哪些。
301
+ **协议段**说明账本是什么、各工具做什么(十四个;工作区功能关闭时不暴露 `report_lease`),以及会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成;工作区若启用,独立的是哪些、共享的是哪些;**会话或网络中断后先对账再继续**——用 `report_list` / `peer_list` 找到最后落下的跳、谁已交付、谁还在线,以汇报正文/任务标签/传递路径为检查点,不从头重做,重试前先确认动作是否已经落地。协议段为了保持前缀逐字节相同而**不按部署改写**,工作区的"有/没有"这一部署差异写在下面的伙伴契约段里。
297
302
 
298
303
  **伙伴契约段**分两种读者:
299
304
 
@@ -316,7 +321,7 @@ worktree:
316
321
  汇报多起来之后时间线需要收窄,所以工具栏提供:
317
322
 
318
323
  - **状态芯片**:`全部 / 进行中 / 已回执 / 已结案`,**标签里带数量**。数量取自完整载荷而非筛选后的行——一个自己会变的标签会让你看不出到底排除了多少。
319
- - **搜索框**:匹配汇报编号、主题、发起者 id 与名称、主送、抄送、共写者、任务标签、产物路径,以及**会话标题与编号**。
324
+ - **搜索框**:匹配汇报编号、主题、发起者 id 与名称、主送、抄送、共写者、任务标签、产物路径、**检出分支与路径**,以及**会话标题与编号**。
320
325
  - **清除**按钮(仅在筛选生效时出现),状态选择记在 `localStorage`,搜索文字刻意不持久化。
321
326
 
322
327
  两条刻意的规则:
@@ -335,7 +340,7 @@ worktree:
335
340
 
336
341
  **不拿会话标题当行标签**,尽管它更「像人话」:`fromName` 取自会话标题,而标题是会话的第一句提示词——它**没有长度上限**,有时比它替换掉的 id 还长(本仓库里的子代理标题正是 `You are doing READ-ONLY research`,四个子代理一模一样)。把定长但不可读的 id 换成不定长且会重复的标题,只是把一种不可读换成另一种,所以名字放在悬停里、放在会话行上,不放在密集行里。
337
342
 
338
- **词表只有一份。** 卡片上的状态芯片复用工具栏筛选芯片**同一批键**(`进行中 / 已回执 / 已结案`):它们本来就在命名同一个状态,给它们两套词(卡上写 `open`、工具栏写 `进行中`)等于把建映射的活推给读者,而那个映射本身就是缺陷。跳动作同理,12 个动作各有词(`发起 / 共写 / 主送 / 送达 / 抄送 / 转呈 / 副本 / 阅读 / 回执 / 修正 / 结案 / 重开`),由 `HOP_LABEL` 对动作全集取 `Record` 保证「新增一个动作不可能没有词」。**账本文件与模型看到的仍是原文 token**——只有人的视图说人话。
343
+ **词表只有一份。** 卡片上的状态芯片复用工具栏筛选芯片**同一批键**(`进行中 / 已回执 / 已结案`):它们本来就在命名同一个状态,给它们两套词(卡上写 `open`、工具栏写 `进行中`)等于把建映射的活推给读者,而那个映射本身就是缺陷。跳动作同理,15 个动作各有词(`发起 / 共写 / 主送 / 送达 / 抄送 / 转呈 / 副本 / 阅读 / 回执 / 认领工作区 / 释放工作区 / 回收工作区 / 修正 / 结案 / 重开`),由 `HOP_LABEL` 对动作全集取 `Record` 保证「新增一个动作不可能没有词」。**账本文件与模型看到的仍是原文 token**——只有人的视图说人话。
339
344
 
340
345
  ### 用平台的组件库,而不是手搓
341
346
 
@@ -343,7 +348,7 @@ worktree:
343
348
 
344
349
  **合法取值是读 CSS 读出来的,不是猜的。** 组件的实现里只用到了部分枚举(`Tag` 的 tone 在代码里只出现 `solid`/`neutral`),但壳里真正渲染的是 CSS 里的 `[data-tone=…]` / `[data-state=…]` 规则——那里定义的是 8 个 tone(`outline / neutral / quiet / solid / info / success / warning / danger`)与 6 个 state(`ongoing / idle / done / warning / error / failed`)。所以状态映射是:**进行中 → `warning`、已回执 → `success`、已结案 → `neutral`;会话在线 → `ongoing`**。只按代码里的用法去猜,会以为只有三种语气色。
345
350
 
346
- **组件不吃 style,吃 `className`,而且落在它自己的 wrapper 上。** 于是本插件唯一自己写的 CSS 是一行布局:把工具栏的搜索框撑满(`client/styles.ts`)。规则挂**我们自己的类名**,绝不碰 hash 类名——那是会随 shell 版本变的东西。
351
+ **组件不吃 style,吃 `className`,而且落在它自己的 wrapper 上。** 于是本插件唯一自己写的 CSS 是一行布局:把工具栏的搜索框撑满(`client/styles.ts`)。规则挂**我们自己的类名**,绝不碰 hash 类名——那是会随 shell 版本变的东西。(泳道视图那套 SVG 绘图规则随它一起删掉了——画布自带 painter,不借这份样式表,于是「唯一自己写的 CSS」重新只剩这一行。)
347
352
 
348
353
  **顺手换来的一个能力**:账本路径旁边多了复制按钮,走 shell 的 `writeClipboard`(而不是 `navigator.clipboard`),这样它继承的是整个 GUI 都在用的那条降级链。
349
354
 
@@ -353,7 +358,7 @@ worktree:
353
358
 
354
359
  列表上方是一张**竖向泳道图**:一条泳道 = 一个会话(列),时间向下(行),每一份汇报画成发起者泳道上的一个节点,它的传递路径画成跨泳道的边。
355
360
 
356
- (**这一版已不在标签页里渲染**:卡片画布接手了图表位,见下面「下一版拓扑」;`TopologyView.tsx` 与 `topology-model.ts` 保留在仓库里、不再被引入,因此不进 bundle,它的几何仍由 `scripts/topology-check.ts` 钉着。下面这段记的是它验证过的东西——结论全部沿用,换掉的只有"节点不承载内容"这一件事。)
361
+ (**这一版已被卡片画布取代,视图与模型也随之删除**:图表位先由卡片画布接手,随后画布整体搬进**右侧栏的标签页**,见下面「拓扑:卡片画布」与「画布住在右侧栏」。下面这段留在这里的是**仍然成立的设计结论**——它们全部被卡片画布继承,换掉的只有"节点不承载内容"这一件事;`TopologyView.tsx`、`topology-model.ts` 与钉住它的 `scripts/topology-check.ts` 已一并清理。)
357
362
 
358
363
  ```
359
364
  泳道(会话, 树序) → afcbce3a │ 01edba72 │ 05286f38 │ … │ 树外
@@ -363,7 +368,7 @@ worktree:
363
368
  19:45 ●R-0003 ┄┄┄┄┄┄┄┄┄┄┄┄▶
364
369
  ```
365
370
 
366
- **为什么是竖向、为什么不需要图引擎。** 这个标签页本来就纵向滚动,时间向下与下面列表的阅读方向一致;而布局**不需要任何图算法**——泳道序就是子树 DFS 序(列表缩进用的同一个序),行序就是时间序(列表排序用的同一个序),两个 rank 都是现成的。通用引擎要解的是**交叉最小化与端口路由**,是这份数据没有的问题,却要为此付出:`elkjs` 是 EPL-2.0/GPL 且解包 8 MB,`d3-dag` 虽是 MIT 但为最优交叉最小化拖进一个线性规划求解器,`@xyflow/react` 1.2 MB 且自带一套样式表。所以这一版**零新增依赖**,几何由 `src/client/topology-model.ts` 这个纯模块给出。
371
+ **为什么是竖向、为什么不需要图引擎。** 这个标签页本来就纵向滚动,时间向下与下面列表的阅读方向一致;而布局**不需要任何图算法**——泳道序就是子树 DFS 序(列表缩进用的同一个序),行序就是时间序(列表排序用的同一个序),两个 rank 都是现成的。通用引擎要解的是**交叉最小化与端口路由**,是这份数据没有的问题,却要为此付出:`elkjs` 是 EPL-2.0/GPL 且解包 8 MB,`d3-dag` 虽是 MIT 但为最优交叉最小化拖进一个线性规划求解器,`@xyflow/react` 1.2 MB 且自带一套样式表。所以这一版**零新增依赖**,几何由当时那个纯模块给出(`topology-model.ts`,已随这一版删除;卡片画布沿用了同一个立场)。
367
372
 
368
373
  **它是什么、不是什么。** 它是**总览**,不是第二个阅读面:节点点击后**滚到列表里那张卡片并展开**——正文、传递路径、待送达仍在同一个地方读。这样图不必处理 4000 字的正文,也不承担"键盘/读屏唯一入口"的责任:列表一直在,图只是加在它上面。
369
374
 
@@ -371,7 +376,7 @@ worktree:
371
376
 
372
377
  **这条横向滚动是刻意的。** 视图自己有一个受控的滚动框(宽出即可横向滚动、高限 46vh),与之前那个"内容撑破整个标签页、滚动条只在最底部够得着"的意外滚动条不同:这里是**包在一个框里**的。默认几何(泳道 120px、沟槽 84px)是照"八条泳道刚好放进一个标签页"定的(实测 1108px,无需横向滚动)。
373
378
 
374
- **布局被 34 条断言钉住**:泳道序等于子树序、节点落在发起者泳道、边落在收件人泳道且同一行、重复收件人只画一条、发给自己的不画、树外落点归到共享泳道、线程边只在线程两端都被画出时才存在、空输入不产生 NaN、同一输入给出逐字节相同的几何(见 `scripts/topology-check.ts`)。
379
+ **布局当时被 34 条断言钉住**:泳道序等于子树序、节点落在发起者泳道、边落在收件人泳道且同一行、重复收件人只画一条、发给自己的不画、树外落点归到共享泳道、线程边只在线程两端都被画出时才存在、空输入不产生 NaN、同一输入给出逐字节相同的几何(见 `scripts/topology-check.ts`;该脚本已随这一版一起删除)。
375
380
 
376
381
  **图例必需,逐边文字不必需。** 四条线型如果没人解释,读者只能猜——所以标题旁有图例,而且它的样例**用的就是画线的同一批 class**(`edgeClass()`),改了线的样子不可能留下一个说谎的图例。但**每条边都挂文字是重复的**:线型已经编码了主送/抄送/共写。所以文字只在**悬停某一份汇报时**出现,回答的是"我正在追的这条路径是什么关系"。这也正是 Mermaid 泳道文档里 "Label Cross-Lane Handoffs" 想说的那件事,只是它的载体是线型 + 悬停,不是常驻标签。
377
382
 
@@ -398,7 +403,7 @@ worktree:
398
403
 
399
404
  ⚠️ **窗口化引入的每个派生列表都必须依赖窗口。** 我把节点列表的数据源从 `layout.nodes` 换成 `shownNodes`,却忘了把它加进 `useMemo` 的依赖数组——结果是**边与时间轴跟着滚动,只有节点不动**,看起来像"滚了但内容没换"。这类 bug 在浏览器里一眼可见(四种滚动位置一测就抓到),而七套断言全都发现不了:纯模型没有错,错的是"React 有没有重算"。所以窗口化的断言只覆盖"哪些行该在窗口里",覆盖不到这一层,这一点要说清楚。
400
405
 
401
- ### 下一版拓扑:卡片画布(参考与实测)
406
+ ### 拓扑:卡片画布(设计、实测与代价)
402
407
 
403
408
  泳道版证明了"关系和归属可以画出来",但它有个致命弱点:**节点是芯片,不承载内容**——一份汇报在图里只剩一个 `R-0001`。下一版改成卡片图,两个决定已经定了:
404
409
 
@@ -428,7 +433,7 @@ worktree:
428
433
  | maxGraph(draw.io) | Apache-2.0,路由/端口/泳道形状齐全 | 渲染是 **SVG**,与"全 canvas"冲突;只取几何/路由又得半个大库。**不引,但抄它的画家抽象。** |
429
434
  | Excalidraw / tldraw | — | 前者是编辑器应用(渲染器不对外复用),后者生产使用需要 license key。**不引。** |
430
435
 
431
- **Step 0 已落地:`src/client/cardgraph-model.ts`(96 条断言)**
436
+ **Step 0 已落地:`src/client/cardgraph-model.ts`(109 条断言)**
432
437
 
433
438
  先做模型、再写画布,因为这份设计要证明的全是数字:哪只帧装哪张卡、线从哪条边走、两条线落在同一条帧边上时各自落在哪、一个视口能看见什么。模型里已经钉死的规则:
434
439
 
@@ -444,9 +449,9 @@ worktree:
444
449
  | **矩形剔除取代窗口化** | 一切带 `bounds` 的东西(帧/卡片/线)用同一个 `cullByBounds` | 自由画布没有"行"可数;且**两端都在屏幕外、中段穿过视口的线必须留下**——泳道版为这条踩过坑,断言里复现了它 |
445
450
  | **视口反变换** | `worldViewport` 把 pan/zoom 换成绘制坐标里的可见矩形 | 坏掉的 `scale`(0/NaN)退化成 1,绝不产生无限或反向矩形 |
446
451
 
447
- 现在**没有任何视图引入这个模块**,所以它对运行时是零成本:重建前后 `lib/client.js` 都是 **89,360 B**(实测,未变)。它由 `scripts/cardgraph-check.ts`(96 条断言,与另外六套一起进 `pnpm test`)钉住。
452
+ 这个模块现在**被视图引入**了(画布就是它画的),所以它不再对运行时零成本——代价与实测见下面 Step 2 的体积账。它由 `scripts/cardgraph-check.ts`(109 条断言,与另外几套一起进 `pnpm test`)钉住。
448
453
 
449
- **Step 1 已落地:`src/client/cardgraph-painter.ts`(78 条断言)**
454
+ **Step 1 已落地:`src/client/cardgraph-painter.ts`(92 条断言)**
450
455
 
451
456
  画家只认一个**结构化上下文** `PaintContext`——十来个方法(填充、路径、文字、一个变换),真 canvas 天然满足它,测试里换成一只**记录器**。于是"这条线被淡出了、那只框没有""画的是虚线还是实线""箭头落在哪个点"全都成了断言,不用开浏览器。这也是 draw.io 的画家抽象真正值钱的地方:换成 SVG 只是再实现一遍这个接口。
452
457
 
@@ -462,23 +467,23 @@ worktree:
462
467
 
463
468
  **Step 2 已落地:`src/client/CardgraphView.tsx`——在真实浏览器里逐条量过**
464
469
 
465
- 标签页现在渲染卡片画布。两只画布(静态 + 交互)、平移缩放、命中、LOD 跟随、点开详情全部做完,并且在**隔离实例 + 真实浏览器**里逐条验证(不是推理):
470
+ 画布是两只层(静态 + 交互)、平移缩放、命中、LOD 跟随、点开详情,全部做完,并且在**隔离实例 + 真实浏览器**里逐条验证(不是推理)。它现在住在**右侧栏的标签页**里(见 [`### 画布住在右侧栏`](#画布住在右侧栏本插件注册的标签页)),会话页那个标签页只剩列表——同一份数据不再有两处渲染。
466
471
 
467
472
  | 验的是什么 | 结果 |
468
473
  |---|---|
469
474
  | 真的画出来了吗 | 底图 `946×420`、100% 不透明;像素里数到文字(8,976 个暗像素)、橙/绿状态色、蓝色线程——四种线型都落了墨 |
470
- | **悬停不重绘底图** | 悬停前后底图墨量**完全相同**(397,320);交互层从 0 变成有内容。这就是两层的意义 |
475
+ | **悬停只重绘交互层** | 底图 effect 的依赖不含 hover,悬停只走交互层;这条曾用「墨量相同」验收,对「重绘了但画得一样」不敏感,重绘次数待浏览器复测 |
471
476
  | 悬停的代价 | 中位 **32.6 ms**,而每次采样等两帧的地板是 ~33 ms(60 Hz)——也就是**测不到开销**,与泳道版当初撞到的同一条地板 |
472
477
  | 点击卡片 | 打开下面列表里的详情(`手工核对几何 dump` 出现在 DOM 里)——图表不另开阅读面 |
473
478
  | 拖拽 | 平移了(按钮的 `left` 变了),**且没有触发打开**——拖动与点击靠 4px 阈值分开 |
474
- | 滚轮 | 以光标为中心缩放(80% → 100% → 80%),`window.scrollY` 始终为 **0**——页面不会在缩放下滚动 |
479
+ | 滚轮 | **平移**(时间向下,滚轮就是往下读的轴);`Ctrl/⌘+滚轮`以光标为中心缩放,一个 100px 的格子正好是按钮的 1.25 倍。两种手势下 `window.scrollY` 始终为 **0** |
475
480
  | LOD 真的跟着缩放 | 卡片档 **8,976** 暗像素 → 芯片档(26%)**301**(只剩帧标题)→ 125% 时 **13,200** |
476
481
  | 无障碍 | 7 张可见卡片各有一个**真按钮**(透明、带 `aria-label`),键盘/读屏可达;列表仍是完整路径 |
477
482
  | 热重载 | 重建 `lib/client.js` 后浏览器**自己换掉**了插件(页内不刷新),这就是改完立刻能看到的原因 |
478
483
 
479
484
  **三处是看了截图才改的**(不是想出来的):
480
485
 
481
- 1. **首屏不是"适应窗口"。** 一开始沿用 fit,结果是 **57%**——落在紧凑档,读者第一眼看到的是一堵单行卡片墙,而卡片画布的卖点恰是被省略掉的主题。改成**按宽度适配、且不低于卡片档**(`clamp(…, 0.8, 1)`):暗像素 **1,743 → 8,976**,帧、列与可读卡片同时在场。"适应窗口"按钮仍然保留,用来看全貌。
486
+ 1. **首屏曾经是另一条规则,现在与控件是同一条。** 最早沿用「整张图塞进盒子」的 fit,结果是 **57%**——落在紧凑档,第一眼是一堵单行卡片墙。于是首屏改成「按宽度适配、不低于卡片档」(`clamp(…, 0.8, 1)`),而 fit 按钮仍是「看全貌」,两条规则各说各话。后来发现它们其实是同一个缺陷的两面:**档位由 zoom 反推、而 zoom 又必须由某个 layout 算出**,单次求解有时没有不动点——会话页实测 `58% ↔ 100%` 往复,窄会话列里 0.8 的下限还会把 750px 宽的图裁进 375px 的盒子。现在只有一条规则:**按宽度、在最富且自洽的档位、放大不超过一个控制步**(`MAX_FIT_ZOOM = ZOOM_STEP`)。首屏就是它,连点三次得到同一个数。**没有空解**:三档都不自洽时(如 2 列树 396–523px)把 scale 夹进最富可负担档的带内,而不是什么都不写——后者会让视图保留上一次变换,正是探测要消除的状态依赖。
482
487
  2. **遮罩从 0.18 改到 0.55。** 第一版照搬泳道版的淡出值,截图一看:其它帧、标题、卡片**全都读不出来了**——追一条路径的代价是把整个上下文赔进去。泳道版自己早就得出过同一条结论的另一半(*该退到后面的是关系,不是参与者*),而画布遮罩比逐个形状淡出更粗暴,所以必须更轻。现在聚焦路径在顶上全强度重绘,其余保持可读。
483
488
  3. **计数说的是"8 个框"而不是"8 个会话"。** 共享的"树外"帧也是一个框,把它算成会话就是在说假话。
484
489
 
@@ -486,12 +491,47 @@ worktree:
486
491
 
487
492
  代价:整个卡片画布(模型 + 画家 + 视图)让客户端半从 **89,360 B → 114,955 B**(gzip 24.86 → 32.17 kB),**+7.3 kB gzip**——而当初被否掉的 konva 光是库自己就要 **+115 kB gzip**。泳道版的 DOM 视图随之下线,这部分是被它自己腾出来的地方抵掉的。
488
493
 
494
+ 把画布搬进右侧栏、并在面板内直接读详情,又让客户端半从 **114,955 B 长到 134,835 B**(gzip 32.17 → 38.88 kB,**+6.7 kB**):标签页类型注册、`fill` 的盒高归属、共用读路径(`timeline-source.ts`)与共用详情(`ReportDetailView.tsx`)——最后两项是消重,不是新增能力。
495
+
489
496
  **留下什么、重写什么**:泳道序(成为帧的排列序)、树外泳道(成为"树外"帧)、四种边的语义、窗口化(升级为矩形剔除)、单层淡出、合并路径、marker 箭头、图例、悬停文字——**全部保留**;泳道的"列 + 行 + 芯片"重写成"帧 + 卡片 + 线"。列表、详情面板、过滤、轮询完全不动。
490
497
 
498
+ ### 画布住在右侧栏(本插件注册的标签页)
499
+
500
+ 画布不再占用「汇报」标签页的版面,而是作为**右侧栏的一个标签页类型**住进产品自己的可停靠面板里。这一节记的是它落地时的决定与实测。
501
+
502
+ **注册三步,一个实现 id。** 类型进 `ctx.sidebarRightTabs`(`kind: ledger-canvas`、`priority: extension`、`guide` 一张胶囊,`order: 15` 落在产品的 `files(10)` 与 `terminal(20)` 之间),正文进 keyed 的 `sidebar.right.pane.tab`,两处用**同一个 `id`**;胶囊是**可发现性的唯一来源**——标签条的 `+` 只会重开引导页,而引导页就按这些胶囊列出类型,没有它这个标签页只能由代码打开。
503
+
504
+ **服务用 `ctx.inject` 等,不写进 `inject` 列表、也不用 `ctx.get`。** cordis 没有可选依赖形式:写进 `inject` 的 key 缺一个就会把这个插件的 fiber 打进 INACTIVE,而在 web boot 上那是**白屏**而不是少一个标签页;`ctx.get` 又读得太早——产品是在自己的 `apply` 里 provide 这个注册表的。`ctx.inject` 是唯一既可选又迟到的形式:服务出现时回调才跑,服务走了它还会再跑。
505
+
506
+ **两个服务面在本地按结构断言,不 import。** 客户端 bundle 的纯度门拒绝跨插件值导入,而且那个包的客户端类型不在第三方插件的可解析依赖集里——这与 `client/index.ts` 里对 `slots`/`locale` 的做法是同一条规矩。
507
+
508
+ **盒子的高度由面板给。** 画布现在只住在右栏(会话页那个 420px 固定盒已随画布搬走,非 fill 分支在 `src/` 里已无调用者);停靠的面板反过来——高度是它给的,固定尺寸会让图漂在半空。`CardgraphView` 保留 `fill`:盒子照样测量,只是由宿主决定谁定高度。
509
+
510
+ **fit:按宽度、在最富且自洽的档位、放大不超过一个控制步**(`MAX_FIT_ZOOM = ZOOM_STEP = 1.25`)。宽度是目标,因为时间向下:横向是"必须装下"的轴,纵向是"拖着读"的轴。探测档位这一步是必需的——档位由 `lodFor(zoom)` 得出、而 zoom 必须由某个 layout 算出,单次求解在宽扁盒子里会来回跳;探测之后结果只由盒子决定。(纯几何在 `fitTransform`;`MIN_ZOOM`/`MAX_ZOOM`/`ZOOM_STEP`/`MAX_FIT_ZOOM` 由 `cardgraph-model.ts` 导出。)放大上限是为了**只有一张卡的会话**:否则"填满宽度"会要求几倍于设计尺寸的缩放,字号变大而信息没多(档位按固定尺寸绘制,超过 100% 只是放大),而且窄到那种程度时就算放到 250% 也填不满。控件文案随之从「适应窗口」改成「填满宽度」——它承诺的是铺满,不再是看全。
511
+
512
+ **滚轮平移、`Ctrl/⌘+滚轮`缩放。** 纵向成了主要阅读方向之后,滚轮就该是"往下读"这一件页面通用手势,缩放挪到修饰键。实现上两处刻意:**只有该轴真有溢出时才动它**(装得下的图不会被滚轮推歪),**行程到 12px 边距为止**(不会把图滚出盒子);平移量按 `deltaMode` 归一(行 ×16、页 ×盒高),缩放对 delta 取指数(100px 一格 = 按钮的 1.25),于是鼠标格子与触控板捏合走同一条曲线。
513
+
514
+ **盒子变了就重新适应——但只跟随"没被读者接管"的视图。** 停靠、分栏、全屏都会在一个按旧盒子算出来的变换之下改变盒子,带着旧偏移的图会被新盒子裁掉(读起来像坏了,而不是像视图被保留);可是刻意放大去看一张卡的人不该被打断。所以有一个 `fittedTransform` 标记:fit 成功置真,任何手动缩放或平移(± 按钮、滚轮、拖拽)置假,只有它为真时盒子变化才重跑 fit。盒子按值比较——观察者每次测量都交来新对象,重测不等于改变尺寸。
515
+
516
+ **点卡片在画布下方读详情**,用的是会话页行内展开的**同一个** `ReportDetailView`(同一份汇报不会在两处读起来不一样),读数据也共用 `timeline-source.ts`(面板骑自己的时间线 tick,会话页按刷新按钮)。面板里没有列表可展开,所以点一次卡片打开、再点一次(或按面板里的收起控件)把画布还回全高。
517
+
518
+ 实测(右栏,真实浏览器):
519
+
520
+ | 场景 | 结果 |
521
+ |---|---|
522
+ | 停靠 575×619 / 4 框 5 汇报 | fit 落在 **79%**,宽度填充 **89%**,无裁剪 |
523
+ | 全屏 1279×648 | **125%**(宽度填充 87%,底部按"宽度优先"溢出,可拖看) |
524
+ | 停靠 ↔ 全屏(未手动动过) | 自动跟随:79% → 125% → 79% |
525
+ | 手动放大两步到 123% 后再全屏 | **保持 123%**,不被重算 |
526
+ | 单卡会话(1 框 1 汇报)全屏 | 封顶 **125%**,宽度只占 **26%**——不被拉伸 |
527
+ | 点卡片 / 再点一次 | 画布 **619 → 278 → 619** |
528
+ | 滚轮(全屏 134% 时) | 下滚 120/120/44/0,总行程 284px = 溢出 260 + 两端各 12;`Ctrl+滚轮` 134% → 107% → 167% |
529
+ | 会话页标签页 | 画布数 **0**——那段「拓扑」已卸下,只剩列表 |
530
+
491
531
  ### 线程、时间基准与阅读上限
492
532
 
493
- - **线程跳转**:详情面板显示该汇报的**上溯**(`parent`)与**下递**(`children`),点任意编号即跳到那一份。链接可以指向**当前树之外**的汇报(它属于另一条线),此时详情仍会打开——`report` 端点按账本范围而非子树范围查询——并明确提示"不在当前会话树的列表里"。
494
- - **时间基准**:一键在「按创建 / 按最近活动」之间切换。会话始终按创建时间;切换只影响汇报在时间轴上的落点。
533
+ - **线程跳转**:详情面板显示该汇报的**上溯**(`parent`)与**下递**(`children`),点任意编号即跳到那一份——目标行会被展开并滚进视口(`block: 'nearest'`)。目标可能在树外、或被当前筛选隐藏,那时它还没有行,跳转就等它的详情到达、合成行挂载之后再滚。链接可以指向**当前树之外**的汇报(它属于另一条线),此时详情仍会打开——`report` 端点按账本范围而非子树范围查询——并明确提示"不在当前会话树的列表里"。
534
+ - **时间基准**:一键在「按创建 / 按最近活动」之间切换。会话始终按创建时间;切换同时改变汇报在时间轴上的**落点**与**行尾显示的那个时间戳**——排序用的轴和读到的轴必须是同一条,否则一张按创建排好的列表会因为行尾写着最近活动而看起来乱序。
495
535
  - **任务标签即分组**:卡片上的 `task` 标签**可点击**——点它就把搜索框设为该标签,于是同一次协作(可能横跨多条会话树)被拉到一起。这是刻意的实现选择:**复用已有的搜索,不引入第二套筛选状态**;`report_list({task})` 在工具侧提供同一维度的分组,并把该任务的 open/acked/closed 统计一并返回。点它还**把状态芯片清回「全部」**:它的提示语承诺的是「只看任务 X」,而只要还有一个状态芯片在收窄,这句话就是假的——实测先筛「已结案」再点任务芯片,原本会得到一句「显示 0/7」,让人以为这个任务没有汇报。
496
536
  - **正文明限**:面板只显示正文开头(>4000 字时截断并提示),与 `report_read` 给模型的上限**保持一致**——让人类视图与模型视图被同样地约束,双方都不会对对方看到的范围感到意外。**范围一致,排版不同**:正文用 shell 自己的 `MarkdownText` 渲染,所以标题、列表、表格、代码块在这里与在对话标签页里长得一样(代码块还带「复制」按钮),而模型拿到的仍是同一段纯文本。**面板里没有第二个滚动条**:正文已经被这个上限约束住了,再套一个 320px 的内层滚动只会在时间线自己的滚动之上抢滚轮;面板随内容变高,滚动统一交给外层。
497
537
  - **截断 Markdown 不等于截断文本**:切点落在代码块中间会留下一个没有闭合的围栏,等于把「代码块到哪里结束」交给渲染器去猜。实测这个渲染器猜得对(切在围栏中间会渲染成一个正常闭合的代码块,没有尾随痕迹),所以 `closeOpenFence()` 是**便宜的保险而不是修一个看得见的坏**——渲染器是插件不拥有的平台模块,它的宽容不是契约,补一个围栏让输出在任何渲染器下都是合法 Markdown。它共 5 条断言,且只在正文真被截断时才跑。
@@ -505,25 +545,25 @@ worktree:
505
545
  |---|---|
506
546
  | `GET /api/report-ledger/timeline?root=<sessionId>` | 该会话的**递归子树**(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略 + 检出游道 |
507
547
  | `GET /api/report-ledger/report?id=<R-0001>` | 单份汇报的缩略 + 完整路径 + 待送达集合 + 正文 |
508
- | `GET /api/report-ledger/worktrees` | **维护视图**:账本知道的每个检出及其磁盘实况、两套预算的当前用量、git 有而账本不知道的检出、悬空条目与警告 |
548
+ | `GET /api/report-ledger/worktrees` | **维护视图**:账本知道的每个检出及其磁盘实况、两套预算的当前用量、账本已用过的仓库里 git 有而账本不知道的检出、悬空条目与警告 |
509
549
 
510
550
  第三个端点是无参数的只读视图,**无论工作区功能开没开都注册**——操作者问"外面有没有东西",答案即使"没有,而且分配是关的"也比 404 有用(404 与打错路径无法区分)。它**只报告、绝不修复**:一个被手工搬走的检出从它自己那侧照常可用,而 git 认为它悬空,所以一条自动化的 `prune` 会毁掉活着的工作;把这个判断留给看着事实的人。
511
551
 
512
552
  它专门呈现两件账本**结构上看不到**的事,因为它们是这个不对称设计必须付的代价:**git 有、而没有任何报告绑定的检出**(人手建的),以及**已被手工删掉、git 报为悬空的条目**。两者都会生成一句警告,并各自给出去处(前者可以 `report_lease` 认领,后者需要 `git worktree repair` 或显式 `prune`)。
513
553
 
514
- 子树由 `ctx.sessionQuery.listSessions()` 的 `parentSession` 链走出,标题只对**子树内存活的会话**查询(长寿命部署里语料远大于一次协作,而标题是装饰、树不是)。汇报按"子树任一成员参与过它的路径"过滤(发起、主送、抄送、共写),因此这是**协作账本**而不是全库倾倒。
554
+ 子树由 `ctx.sessionQuery.listSessions()` 的 `parentSession` 链走出,标题只对**通过子树筛选的会话**查询(长寿命部署里语料远大于一次协作,而标题是装饰、树不是)。汇报按"子树任一成员参与过它的路径"过滤(发起、主送、抄送、共写),因此这是**协作账本**而不是全库倾倒。语料列表在宿主侧复用 5 秒、并发请求共用同一次枚举,并在 `agent/created` 时立即失效;汇报缩略与检出泳道仍每次请求现读。
515
555
 
516
556
  为什么不用 typert 生成的 Remote:那需要构建期代码生成。第三方插件的通行做法是自建同源、仅限 loopback 的 JSON 通道,本插件照此实现。**两半共用 `src/shared/wire.ts` 的类型**——该文件只有 `export type`,会被完全擦除,所以浏览器 bundle 不可能内联宿主代码(构建后已核验:外部依赖仅 `react`、`react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives`,node 内置模块与 yaml 均为 0 命中)。
517
557
 
518
- 那个平台模块的类型是**声明出来的,不是导入的**(`src/client/platform.d.ts`):它只存在于 shell 自己的 bundle 里,不在 profile 的 `node_modules` 层——而 `tsconfig` 的 `@deepseek-ai/*` 正映射到那一层。这与 `client/index.ts` 给 `slots` / `locale` 写结构化接口是同一个做法,理由也一样:**断言我们实际调用的形状,不多声明一个字段**(多声明的字段在运行时会被静默忽略)。
558
+ 那个平台模块的类型是**声明出来的,不是导入的**(`src/client/platform.d.ts`)。但**它在 DSH 部署里其实解析得到**(`tsconfig` 的 `@deepseek-ai/*` 就映射到 profile 那一层,实测该包完整存在),而一条 ambient `declare module` 会**压过**真实类型:`tsc` 校验的是手写形状,不是 shell 的 API——实测一个平台里根本不存在的导出名照样通过。保留声明的理由是移植性(没装 DSH 的检出也必须能 typecheck,而第三方插件不能把 shell 的包当依赖),代价是平台改动对 `tsc` 不可见,所以升级 DSH 后必须按 `platform.d.ts` 文件头列的五项复核(`MarkdownText.labels` 必填、`Tag` 没有 `onClick`/`title`、`StateDot` 五个状态、`Button`/`Input`/`Pill` 吃原生属性、`writeClipboard` 返回 `Promise<boolean>`)。这与 `client/index.ts` 给 `slots` / `locale` 写结构化接口是同一个做法:**断言我们实际调用的形状,不多声明一个字段**。
519
559
 
520
- **视图会自己跟上。** 账本是别的 agent 在后台写的,所以只取一次的快照会在最需要它的那一刻过期:打开期间每 10 秒重取一次时间线,页面不可见时跳过(后台标签页零成本),切回该浏览器标签页时立刻补一次。「刷新」按钮保留,并且**只有它**会连已打开的详情面板一起重取——轮询刻意不碰详情,否则正文每 10 秒闪回一次「正在读取传递路径」。两个计数器(`timelineNonce` / `detailNonce`)就是为这个区分存在的。
560
+ **视图会自己跟上。** 账本是别的 agent 在后台写的,所以只取一次的快照会在最需要它的那一刻过期:打开期间每 10 秒重取一次时间线,页面不可见时跳过(后台标签页零成本),切回该浏览器标签页时立刻补一次。「刷新」按钮保留,并且**只有它**会连已打开的详情面板一起重取——轮询刻意不碰详情,否则正文每 10 秒闪回一次「正在读取传递路径」。两个计数器(`timelineNonce` / `detailNonce`)就是为这个区分存在的。(这条是**会话页**的规则;停靠的画布面板反过来,它骑时间线 tick 一起重取详情。所以 `useReportDetail` 在**同一份汇报**重取期间保留旧正文、换一份汇报才清空——否则那块面板每 10 秒闪一次。另外,一次失败的重取不再替换列表:有载荷时只在顶部加一条带「重试」的警示条,整页错误页只留给「还没有任何数据」的时候。)
521
561
 
522
562
  ### 守卫与信任假设
523
563
 
524
564
  三个端点都是 GET、只读、无写入面(所有变更仍只走模型工具,那是唯一会记录跳的路径)。守卫照实复刻部署中第三方插件的做法:**TCP 对端必须是 loopback** + **`Host` 必须解析为自身且是 loopback 主机名** + **浏览器同源标记一致**。第二项挡掉 DNS rebinding 拼法(`localhost.attacker.tld`)与非规范权威(默认端口 `127.0.0.1:80` 解析后会消失,故不相等)。
525
565
 
526
- ⚠️ **信任假设要说清楚**:实测发现**已注册的 exact 路由先于鉴权匹配**——未注册路径返回 401,而注册过的路由直接 200,不要求会话 cookie。所以守卫是这些端点**唯一**的防护,其信任边界是"本机进程",与账本文件本身可被本机读取是同一信任级。反向代理部署需要守卫的共享令牌变体;只服务直接 loopback 是安全的默认,失败模式是"读被拒绝"而非"读被泄露"。
566
+ ⚠️ **信任假设要说清楚**:实测发现**已注册的 exact 路由先于鉴权匹配**——未注册路径返回 401,而注册过的路由直接 200,不要求会话 cookie。所以守卫是这些端点**唯一**的防护,其信任边界是"本机进程",与账本文件本身可被本机读取是同一信任级。反向代理部署需要守卫的共享令牌变体;只服务直接 loopback 是安全的默认,失败模式是"读被拒绝"而非"读被泄露"——500 只回 `{ok:false,code:"internal-error"}`,路径与异常原文只写进部署日志。
527
567
 
528
568
  ## 同伴:代理自主开启会话
529
569
 
@@ -536,7 +576,7 @@ worktree:
536
576
 
537
577
  ### 同伴是独立根会话,不是下属
538
578
 
539
- 这是本阶段最重要的设计判断。`peer_start` 创建的会话**没有 `parentSession`、没有 `origin`、`delegationDepth` 为 0**——它是一个根会话。这样它才配得上"同伴":不占用任何委派深度预算、出现在工作区会话列表里、**并且比开启它的那一轮活得更久**。
579
+ 这是本阶段最重要的设计判断。`peer_start` 创建的会话**没有 `parentSession`、没有 `origin`、`delegationDepth` 为 0**——它是一个根会话。这样它才配得上"同伴":不占用任何委派深度预算、出现在工作区会话列表里(见下节:这一步需要显式挂账,不会自己发生)、**并且比开启它的那一轮活得更久**。
540
580
 
541
581
  "独立"说的是**生命周期**,不是**能力**。新生的根会话自己既没有组合(composition)也没有模型路由,缺哪一样它的第一个回合都会当场死掉:没有组合就只看得见空的全局层(没有预设工具、没有预设提示词段);没有模型路由则在提示词组装时解析不出 `{{model}}`——回合在第 1 步就以 `error` 结束,**一个工具都没调到**。所以这两样必须**从开启者身上取**:
542
582
 
@@ -559,15 +599,49 @@ worktree:
559
599
 
560
600
  这是一次**观测**而不是状态查询:它把"交接完成"与"有人在干"分开,代价是 `peer_start` 多等一个窗口。
561
601
 
562
- ### 授权规则
602
+ ### 同伴进哪个工作区分组
563
603
 
564
- DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、teams、workflows、hosts 保持拒绝,**直到有一个显式的授权协议有生产消费者**"。本插件就是那个消费者,它实现的规则故意收得很窄:
604
+ "出现在工作区会话列表里"这句话需要补一句:**它不会自己发生**。
565
605
 
566
- **血缘授予通道,开启过的会话授予通道——而"仅仅在账本里有往来"不单独授予通道。**
606
+ 侧边栏的分组只读工作区的账目(`workspace.json` 里每个工作区的 `sessionIds`),而这个账目只有两个来源:
567
607
 
568
- 最后一条是刻意的:正因为"通过账本联系对方"是建立接触的方式,把接触本身当作授权就形成了循环——先有鸡还是先有蛋。所以**主动伸手(`report_send`/`report_cc`)永远允许**(它会产生那条记录),而**直接通道只来自血缘或"我开启了它"**。
608
+ 1. **一次性历史对账**——注册表首次初始化时(`initialized` 标志为 false 的那一次)把已有会话按规范路径分桶补录。**之后永不重跑**:非首次启动时只做「按 header 索引重建」,而那个索引的用途是**剔除**路径对不上的条目,不是补录;
609
+ 2. **创建时显式挂账**——调用方在 create 请求里点名 workspaceId。GUI 里从某个工作区新建会话走的就是这条;**Fork** 也走(继承源会话的工作区)。
569
610
 
570
- 创建会话是本插件唯一会**新增活代理**的能力(其余都只是记录),所以它的授权故事是叠加的:工具可见性(DSH 自己认定的唯一真实闸门)+ `maxPeersPerAgent` 预算(默认 8,超限是明确的工具错误而非静默拒绝)+ **继承调用者自己的工作目录**(无法被指向无关目录树)+ 每次创建都在名册里留痕。
611
+ `peer_start` 两条都够不着:`ctx.agents.create()` 的创建元数据只有 `cwd` 和 `agentPreset`,**没有工作区身份**;而对账在同伴存在之前早就跑完了。于是出现一个很别扭的状态——**同伴的工作目录与某个已注册工作区逐字相等,它的 id 却不在那个工作区的账目里**,浏览器按账目分组,就把它永远放在「未分组」。
612
+
613
+ 修法是宿主侧一个**可选钩子**(`PeerHost.attachToWorkspace`):同伴**创建之后**(注册表要校验会话头里的 `cwd`,而头在会话存在之前不存在),宿主解析同目录的工作区并把新会话挂进去。三个设计要点:
614
+
615
+ - **按规范真实路径匹配**,不做字符串比较。注册表校验的是 `realpath` 归一化后的路径,用字符串比较会在大小写、分隔符、符号链接、8.3 短名上产生"要么挂错、要么被注册表拒绝"的分歧。所以宿主用的是注册表自己的 `resolveByPath()`。
616
+ - **可选依赖,不是硬依赖**。headless profile 没有工作区注册表(没有侧边栏可分组),硬依赖会让插件在那里永远等待。钩子缺失时报告"这个部署没有分组",而不是失败。
617
+ - **三种结果,"只有一种是缺陷"**:`registered`(已挂账,报出工作区标题)、`no-workspace`(没有注册表,**或**该目录确实不属于任何工作区——同伴在自己的临时检出里就是这种,合法)、`failed`(尝试了并抛错)。`peer_start` 只对第三种打警告,因为对合法情形也警告,会让调用者学会忽略那一行——而那一行正是本机制存在的理由。注册失败**绝不**让创建失败:一个存在但归错桶的同伴仍是能用的同伴,被拒绝创建的同伴什么都不是。
618
+
619
+ 这条路径正是被真实数据打出来的:本机 166 个会话里有 12 个根会话长期停在「未分组」,其中一个目录(`D:\Projects\dsh`)根本没有任何工作区记录。
620
+
621
+ ### 这一段的验证分两层
622
+
623
+ `pnpm test` 里跑的是**确定性**检查(`scripts/roster-check.ts` 管服务侧、`scripts/workspace-check.ts` 管宿主适配侧)。它们用的是假注册表,因此证明不了「真实注册表会不会接受这次挂账」——而答案恰恰在注册表自己的校验规则里:`attachSession` 会读会话头、把 `cwd` 归一化,然后要求它**等于**工作区记录里的路径。用字符串比较代替归一化,注册会全部返回 `failed`、同伴照样停在未分组,而单元测试全绿。
624
+
625
+ 所以还有一个**手动**探针(不进 `pnpm test`,因为它需要真实存储栈与真实会话日志):
626
+
627
+ ```powershell
628
+ # 在隔离的 DSH_HOME 上放一份真实 workspace.json 的拷贝(绝不写机器上那份),
629
+ # 再指向真实会话日志根,让真实 WorkspaceRegistry 跑一遍完整链路
630
+ New-Item -ItemType Directory -Force "$env:TEMP\dsh-probe-x\storages" | Out-Null
631
+ Copy-Item "$env:USERPROFILE\.dsh\storages\workspace.json" "$env:TEMP\dsh-probe-x\storages\"
632
+ $env:DSH_HOME = "$env:TEMP\dsh-probe-x"; $env:SESSIONS = "$env:USERPROFILE\.dsh\sessions"
633
+ node scripts/registry-attach-probe.ts 'D:\Projects\dsh-report-ledger' 'session-<真实会话 id>'
634
+ ```
635
+
636
+ 它挂载真实的 JSON 存储后端、真实 storage domain、真实 JSONL 持久化与真实注册表(离线显式跑一次服务的 init hook,因为这里没有 Loader),然后断言:注册表能看见拷贝里的 8 个工作区、非规范拼写(全大写 + 尾随分隔符)也解析到规范路径、插件自己的适配层能把一个**真实会话**挂进去、活体读回成员已生效、重复挂账幂等。5/5 通过,即证明这条链在生产包上成立。
637
+
638
+ ### 关系分类,不是投递授权
639
+
640
+ DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、teams、workflows、hosts 保持拒绝,**直到有一个显式的授权协议有生产消费者**"。本插件**不声称**自己是那个消费者:它画的是关系图,不是门禁。
641
+
642
+ **`report_send` / `report_cc` / `report_forward` 按设计接受任何会话 id**——正因为"通过账本联系对方"是建立接触的方式,把接触本身也设成门禁就形成了循环(先有鸡还是先有蛋)。所以本插件**没有投递前的权限校验**:血缘、同父兄弟、"我开启的会话"、账本里有往来,这些只决定 `peer_list` 里显示什么关系、怎么排序;`holdsChannelTo` 在投递路径上没有任何调用者。真实存在的闸门只有两条:**工具可见性**与 **`maxPeersPerAgent` 预算**。
643
+
644
+ 创建会话是本插件唯一会**新增活代理**的能力(其余都只是记录),所以它的授权故事是叠加的:工具可见性(DSH 自己认定的唯一真实闸门)+ `maxPeersPerAgent` 预算 + **继承调用者自己的工作目录**(无法被指向无关目录树)+ 每次创建都在名册里留痕。预算是**本会话终身**额度(默认 8),不是并发额度:已经离开、或首轮就死掉的同伴**照样占名额**,名册日志就是计数;超限是明确的工具错误,错误信息会说明这一点并给出回收办法——**备份后**从 `$DSH_HOME/report-ledger/peers.jsonl` 删除已结束同伴的那几行(append-only JSONL,删行只影响计数与关系归类)。
571
645
 
572
646
  ### 创建与对话是分开的
573
647
 
@@ -583,9 +657,10 @@ DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、
583
657
 
584
658
  ## 工程约定(踩过的坑)
585
659
 
586
- - **绝不新增自定义会话事件类型。** `SessionEventMap` 看起来可扩展,但持久化读取路径 `assertEventsSupported` 只在 `KNOWN_SESSION_EVENT_TYPES` 命中或事件带 `ignorable: true` 时放行,而 `Session.append` 从不设置 `ignorable`;白名单是从仓库内成员生成的字面量。追加新类型会让**该会话日志在重载时不可读**。因此账本走文件,模型可见的摘要走 `source: {kind:'plugin', plugin:'report-ledger', form:'relay'}`(既有已知形状)。
660
+ - **绝不新增自定义会话事件类型。** `SessionEventMap` 看起来可扩展,但持久化读取路径 `assertEventsSupported` 只在 `KNOWN_SESSION_EVENT_TYPES` 命中或事件带 `ignorable: true` 时放行,而 `Session.append` 从不设置 `ignorable`;白名单是从仓库内成员生成的字面量。追加新类型会让**该会话日志在重载时不可读**。因此账本走文件,模型可见的摘要走 `source: {kind:'plugin:report-ledger', form:'relay'}`(session format v4 要求生产者自有 kind;旧的 catch-all `plugin` 会被 admission 拒收,那会让接收方的首轮在第 1 步就死掉)。
587
661
  - **官方包必须保持 external。** 内联 `dsh-tools` 会复制服务注册表、破坏实例同一性。构建只内联真正的第三方依赖(`yaml`)。
588
662
  - **运行时解析需要 `node_modules/@deepseek-ai` junction。** Node 按 **realpath** 解析模块:插件包经 `~/.dsh/profiles/node_modules/dsh-report-ledger` junction 指向本仓库后,真实路径仍在工作区,因此 `import '@deepseek-ai/dsh-tools'` 只会从本仓库向上查找。工作区里的 `node_modules/@deepseek-ai` → profile 官方包层的 junction 正是为此,与生态里 `link-profile.mjs` 的做法一致。**`pnpm install` 可能清掉它,重装后需重建。**
663
+ - **跨插件导入宿主值只允许纯函数,且必须走官方 export。** 首例是 `realpathNormalize`(`@deepseek-ai/dsh-workspace`):注册表校验会话目录用的是 `realpath` 归一化,用字符串比较替代它就会把同伴挂错组或被注册表拒绝。这类导入的判据是「有没有要被复制的运行时身份」——纯路径函数没有(无注册表、无生命周期、无状态),而任何服务类都**有**(内联一份 `dsh-tools` 会复制服务注册表、破坏实例同一性,见上一条)。这条 import 是**静态**的,因此部署必须能解析到 `@deepseek-ai/dsh-workspace`(CLI 通过 `dsh-web-app` 依赖它,所以实际总在);可选的是 `workspaceRegistry` **服务**,不是这个包——服务缺席时 `attachToWorkspace` 返回 `no-workspace`。
589
664
  - **工具参数规范中不能写 `required: false`。** `defineTool` 的 `ParameterSchemaSpec` 只接受 `required: true` 或**整个省略**,写 `false` 会在加载时报 `required must be true when present`。可选参数就是不带 `required` 的字段。
590
665
  - **投递与账本分层。** `deliver()` 只做传输、绝不碰账本;到达跳由 `ReportService` 统一写入,保证审计的写者唯一。
591
666
  - **可选服务用 `ctx.inject`,不要用 `ctx.get`——两者对"可能后到的服务"并不等价。** `ctx.get` 读的是**此刻**的注册表,provider 还没激活就返回 `undefined`;服务注入回调则在服务**真正出现时**运行。本项目在 web profile 上因此真实踩坑:`webserver` 行 inject 了 `webStartup`,会晚于我们的行激活,于是路由**被静默跳过**,所有请求落到 `/api` 鉴权栅栏上得到 401,而我们的 handler 从未被执行。之所以不用声明式 `inject: ['webServer']`(那样必然排在后面):headless profile 根本没有 web server,硬依赖会让整个插件在那里永远等待、什么也不贡献。`ctx.inject(['webServer'], (scoped) => …)` 同时满足两者——可选,且与到达顺序解耦。
@@ -605,30 +680,35 @@ DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、
605
680
 
606
681
  ```sh
607
682
  pnpm build # 产出 lib/index.js(host 半)与 lib/client.js(client 半)
608
- pnpm test # 十二套确定性检查共 859 项断言:账本内核与生命周期 96 + 提示词角色分流 55
609
- # + 时间线装配与路由守卫 72 + 同伴名册与创建 46
610
- # + 时间线模型(筛选/线程/空状态/身份/正文/围栏)96
611
- # + 拓扑布局(泳道/节点/边/树外/线程路由/确定性)51
612
- # + 卡片画布模型(分档尺寸/帧装箱/收件口/沟槽/剔除/视口/拾取)96
613
- # + 卡片画布画家(绘制顺序/裁剪/虚线/箭头/遮罩/主题/折行/度量缓存)82
614
- # + worktree 适配器(porcelain 解析/路径归一化/git 拒绝分类/格式版本闸门)76
615
- # + 租约裁判(双层占用/并发只赢一个/凭证来自 git/交还与结案)74
616
- # + 分配(命名 ref 安全性/两套预算/回收与脏跳过/幽灵终态/回滚)67
617
- # + 维护视图(六种检出状态/未绑定检出/悬空条目/故意移除不误报/路由守卫)47
683
+ pnpm test # 十套确定性检查共 887 项断言:账本内核与生命周期 135 + 提示词角色分流 62
684
+ # + 时间线装配与路由守卫 77
685
+ # + 时间线模型(筛选/线程/空状态/身份/正文/围栏)102
686
+ # + 卡片画布模型(分档尺寸/帧装箱/收件口/沟槽/剔除/视口/拾取/填满宽度)109
687
+ # + 卡片画布画家(绘制顺序/裁剪/虚线/箭头/遮罩/主题/折行/度量缓存)92
688
+ # + worktree 适配器(porcelain 解析/路径归一化/git 拒绝分类/格式版本闸门)83
689
+ # + 租约裁判(双层占用/并发只赢一个/凭证来自 git/交还与结案)84
690
+ # + 分配(命名 ref 安全性/两套预算/回收与脏跳过/幽灵终态/回滚)95
691
+ # + 维护视图(六种检出状态/未绑定检出/悬空条目/故意移除不误报/路由守卫)48
618
692
  #(不需要 DSH,不触碰真实账本,不启动服务器;worktree 相关的四套会在临时目录里建真仓库)
619
- pnpm test:tools # 工作区工具面 50 项 —— 只能在有官方包层的地方跑,见下
693
+ pnpm test:tools # 工作区工具面 + 宿主接线 + 同伴名册 162 项(55 + 38 + 69)—— 只能在有官方包层的地方跑,见下
620
694
  pnpm typecheck # 对部署中的 harness 类型做全量类型检查
621
695
  pnpm check # 以上三者的合并,也就是完整的本地门禁
622
696
  ```
623
697
 
698
+ 开发与测试需要 **Node ≥ 22.6**(脚本直接跑 `.ts`,依赖 type stripping);运行产物本身只要求
699
+ Node ≥ 20(见 `engines`)。
700
+
624
701
  **为什么工具面那一套不在 `pnpm test` 里。** `scripts/worktree-tools-check.ts` 要运行期 import
625
- `src/report/tools.ts`,而后者运行期 import `@deepseek-ai/dsh-tools`——一个**未在 package.json 里
626
- 声明**的包:本机靠 `node_modules/@deepseek-ai` 的 junction 解析,而 lockfile 里没有它,所以 runner
627
- 上 `pnpm install` 之后必然解析不到。
702
+ `src/report/tools.ts`,而后者运行期 import `@deepseek-ai/dsh-tools`;`scripts/workspace-check.ts`
703
+ 还要 import `src/index.ts`(`@deepseek-ai/dsh-workspace`);`scripts/roster-check.ts` 的同伴创建测试也
704
+ 要 import `src/report/tools.ts`——三者都是**未在 package.json 里声明**的包:本机靠
705
+ `node_modules/@deepseek-ai` 的 junction 解析,而 lockfile 里没有它们,所以 runner 上
706
+ `pnpm install` 之后必然解析不到。这就是这三个脚本放在 `test:tools` 而不是 `test` 的原因:
707
+ 可移植子集必须只依赖 `yaml` 与 Node 内置模块。
628
708
 
629
709
  这与 typecheck 面对的是同一个约束(CI 里没有官方包层),处理方式也刻意一致:**CI 跑它跑得了的
630
710
  那部分,跑不了的留在本地门禁里**。`pnpm check` 是完整门禁,`pnpm test` 是它可移植的子集——`ci.yml`
631
- 与 `publish.yml` 都只跑后者。把这个分工写在脚本名上而不是注释里,是为了失败信息不至于指向一个与
711
+ 与 `publish.yml` 都只跑后者;`ci.yml` 另在构建后核对已提交的 `lib/`,`publish.yml` 只测试加构建。把这个分工写在脚本名上而不是注释里,是为了失败信息不至于指向一个与
632
712
  被测代码毫无关系的模块名。
633
713
 
634
714
  **安装到 profile**(本仓库已这么装好):包经 junction 出现在 `~/.dsh/profiles/node_modules/dsh-report-ledger`,并在 profile 的 `cordis.patch.yml` 中有一行:
@@ -678,10 +758,12 @@ pnpm check # 以上三者的合并,也就是完整的本地门禁
678
758
  ### 发布(维护者)
679
759
 
680
760
  分发形态是**预构建的 bundle**:`lib/` 在发布前构建好,用户安装时不跑任何构建脚本,因此不需要 `allowBuilds` 授权。
761
+ `lib/` 同时**随源码提交**(DSH STORE 的固定源检查要求运行产物出现在固定 Commit 里),`ci.yml` 会在构建后比对
762
+ 提交的产物与源码是否一致,避免"改了 `src` 忘了 `build`"。
681
763
 
682
764
  ```sh
683
- pnpm check # 完整门禁:类型检查 + 十二套可移植检查 + 工具面那一套
684
- pnpm pack # 先出 tarball 核对产物(prepare 会顺带构建)
765
+ pnpm check # 完整门禁:类型检查 + 十套可移植检查 + 工具面那一套
766
+ pnpm build && pnpm pack # lib/ 随源码提交,pack 前显式重建一次
685
767
  npm publish # ⚠ 本机 registry 若是镜像站,必须显式 --registry=https://registry.npmjs.org
686
768
  ```
687
769
 
@@ -694,7 +776,7 @@ npm publish # ⚠ 本机 registry 若是镜像站,必须显式 --regis
694
776
  | `dsh.bundle.patch` 指向该 patch | `./cordis.patch.yml` |
695
777
  | `version` 已递增 | npm 不允许覆盖已发布版本 |
696
778
 
697
- 发布后**在干净环境里验证**(本仓库已按此验证过 0.1.0 的 tarball):
779
+ 发布后**在干净环境里验证**(0.1.0 的 tarball 已按此验证过;0.2.0 已发布,但仓库里没有留下同等的安装记录):
698
780
 
699
781
  ```sh
700
782
  dsh plugin --profile demo add dsh-report-ledger # 空 DSH_HOME 里
@@ -712,9 +794,11 @@ npm trust github dsh-report-ledger --file publish.yml \
712
794
  ```
713
795
 
714
796
  `--file` 必须与工作流文件名完全一致。之后发版就是 `pnpm version patch && git push origin main --follow-tags`。
797
+ `publish.yml` 在 tag 与 `package.json` 的 version 不一致时会直接失败(`workflow_dispatch` 仍保留给新包名的手工首发)。
715
798
  注意 CI 只跑 `pnpm test` + 构建,**不跑 typecheck、也不跑 `pnpm test:tools`**:两者的原因相同——都需要
716
799
  profile 的官方包层(见下文 junction 一节),CI 里没有这一层;把官方包装成 devDependency 反而会在工作区
717
- 复制服务注册表、破坏实例同一性。
800
+ 复制服务注册表、破坏实例同一性。`scripts/workspace-check.ts` 需要 `@deepseek-ai/dsh-workspace`,所以它在
801
+ `test:tools` 而不是 `pnpm test`。
718
802
 
719
803
  **`ci.yml` 只跑测试,不发布。** 在它之前,本仓库**唯一**跑测试的地方就是 `publish.yml`——而它一推 tag 就
720
804
  发布。也就是说这套断言从未在 Linux 上跑过,却要拿它决定一个版本能不能发出去。`ci.yml` 把这两件事分开:
@@ -726,9 +810,10 @@ profile 的官方包层(见下文 junction 一节),CI 里没有这一层
726
810
  在浏览器里完成认证即可(放行凭据用 `--//registry.npmjs.org/:_authToken=…` 传,别写进 `.npmrc`)。
727
811
 
728
812
 
729
- **git 安装与 npm 安装不是一回事**:`add github:<你>/dsh-report-ledger#<sha>`(或 Gitee 地址)拉到的是**源码**,
730
- 靠仓库里的 `prepare` 构建出 `lib/` 才能跑——本仓库实测在 pnpm 10.14 上直接通过、未要求 `allowBuilds`,
731
- 但部分 pnpm 版本会拦截依赖的构建脚本,届时 `dsh` 会打印出要写进 profile 的 `pnpm-workspace.yaml` 的包键。
813
+ **git 安装与 npm 安装现在是同一件事**:`lib/` 随源码提交,`add github:<你>/dsh-report-ledger#<sha>`(或 Gitee 地址)
814
+ 拉到源码后**直接就带着可运行的构建产物**,安装期不执行任何生命周期脚本、不需要 `allowBuilds` 授权。
815
+ (这条从提交 `db6741c` 起才成立:`v0.2.0` 及更早的 tag 没有提交 `lib/`,安装期靠 `prepare` 构建——那既要求
816
+ pnpm 放行构建脚本,也过不了 DSH STORE 的固定源检查。)
732
817
  **对外仍推荐 npm 安装**:预构建产物、不触发任何构建脚本、不需要授权。
733
818
 
734
819
  #### Gitee 镜像
@@ -736,30 +821,35 @@ profile 的官方包层(见下文 junction 一节),CI 里没有这一层
736
821
  Gitee 自带的「仓库镜像管理」在本账号不可用(`GET /api/v5/repos/{owner}/{repo}/mirror` 返回
737
822
  `404 Not Found Project`),所以同步方向反过来:GitHub 主动推。
738
823
 
739
- - 工作流 `.github/workflows/mirror-to-gitee.yml`,在 `main` 与 `v*` tag 的 push 后镜像 `main` + tags;
824
+ - 工作流 `.github/workflows/mirror-to-gitee.yml`,在 `main` 与 `v*` tag 的 push 后镜像**所有分支 + tags**;
825
+ Gitee 上已存在的 tag 不会被更新(只补缺失的),所以同一个 tag 在两边的对象类型不同也不会让 job 变红;
826
+ - **tag 推送(或手动 `workflow_dispatch`)还会创建/复用 Gitee release,并把从该 tag 归档构建的 tarball
827
+ 附上去**(`scripts/gitee-release.sh`,幂等;main 推送只镜像 git 对象,不碰 release API);
740
828
  - 凭据是 GitHub 仓库 Secret `GITEE_TOKEN`(Gitee 私人令牌,只需 `projects` 权限)。
741
829
  **令牌有有效期,过期后要重新生成并 `gh secret set GITEE_TOKEN`**,否则工作流会认证失败;
742
830
  - 令牌只经 `credential.helper` 按需交给 git,不写进 remote URL,所以不会落进 `.git/config` 或命令输出。
743
831
 
744
- 发行版附件**不在自动同步范围内**,需要单独上传;注意附件接口要求令牌放在 **query** 上,
832
+ release 创建/上传失败时,先 `gh workflow run mirror-to-gitee.yml --ref main` 重跑(脚本会复用已有 release、
833
+ 已存在的附件不重复上传);也可以按下面的手工命令单独补传。注意附件接口要求令牌放在 **query** 上,
745
834
  放 form 里会得到 `401 登录失效`(实测):
746
835
 
747
836
  ```sh
748
837
  curl -X POST "https://gitee.com/api/v5/repos/stone_zhan/dsh-report-ledger/releases/<release_id>/attach_files?access_token=<token>" \
749
- -F "file=@dsh-report-ledger-0.2.0.tgz"
838
+ -F "file=@dsh-report-ledger-0.2.2.tgz"
750
839
  ```
751
840
 
752
841
  ## 已知限制
753
842
 
754
- - **单进程假设。** 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。工作区的**占用判定**另有一把按仓库为键的锁(跨报告抢同一支分支是单报告锁看不见的),但它同样只在本进程内有效。
755
- - **正文并发覆盖。** 跳流 append-only 永不丢跳,但两个人同时改写同一份正文是后写者胜(`report_contribute` 用追加,规避了常见路径)。
843
+ - **单进程假设。** 每个汇报的写操作——**包括 `report_read` 记跳与延迟投递泵**——都由进程内按汇报的互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。工作区的**占用判定**另有一把按仓库为键的锁(跨报告抢同一支分支是单报告锁看不见的),但它同样只在本进程内有效。
844
+ - **正文并发覆盖。** 跳流 append-only 永不丢跳,**单进程内**整份重写的写入点都已进锁;**两个进程**同时改写同一份正文仍是后写者胜(`report_contribute` 用追加,规避了常见路径)。
845
+ - **同伴名额是终身的。** `maxPeersPerAgent`(默认 8)数的是名册日志里的行,不区分同伴是否还在、是否首轮就死;没有退役工具。回收是人工动作:备份后从 `$DSH_HOME/report-ledger/peers.jsonl` 删掉已结束同伴的行。
756
846
  - `fromName` 取自会话标题,是"会话名"而非"代理名"。
757
847
 
758
848
  工作区相关的限制(该功能默认关闭,未开启时都不适用):
759
849
 
760
850
  - **隔离是有限的。** 独立的是工作目录与索引;`config`、`hooks`、对象库与检出之外的一切都共享。装一个 hook 会影响所有检出的每一次提交。
761
851
  - **没有"接管"策略。** 缺席的持有者不会被抢走检出;只能等它自己 `report_release` 或结案。
762
- - **维护视图只报告,不修复。** `GET /api/report-ledger/worktrees` 会列全每个检出的磁盘实况与每一处异常,但清理仍然是人(或 `report_close` 的 `remove-if-clean`)的动作。自动 `prune` 会毁掉一个被手工搬走、却从自己那侧照常可用的检出,所以它不在这里做。
852
+ - **维护视图只报告,不修复。** `GET /api/report-ledger/worktrees` 会列全**账本已用过的仓库里**每个检出的磁盘实况与每一处异常,但清理仍然是人(或 `report_close` 的 `remove-if-clean`)的动作。自动 `prune` 会毁掉一个被手工搬走、却从自己那侧照常可用的检出,所以它不在这里做。它的 `retained` 与分配预算的 `retained` 数的不是同一集合(它含非账本自建、报告未结案的检出),警告里点名的可回收对象以分配侧为准。
763
853
  - **回收是自发的、不是排定的。** 只有在下一次分配超出 `maxRetained` 时才会清一个;平时不会主动整理。
764
854
  - **绑定不能清空,只能覆盖。** `report_amend` 不管绑定;要改正一个指向已消失检出的绑定,办法是 `report_lease` 认领另一个有效检出。
765
855
  - **含子模块的仓库直接拒绝分配**,没有逃生开关。