dsh-report-ledger 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.1.0/dsh-report-ledger-0.1.0.tgz
35
- dsh plugin --profile web add ./dsh-report-ledger-0.1.0.tgz
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
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,17 +44,25 @@ GitHub 每次推送后由 `.github/workflows/mirror-to-gitee.yml` 自动镜像 `
44
44
 
45
45
  ### 装完你会得到什么
46
46
 
47
- - **宿主半**(任何 profile):十个 `report_*` 工具 + `peer_list` / `peer_start`、
47
+ - **宿主半**(任何 profile):十二个 `report_*` 工具 + `peer_list` / `peer_start`、
48
48
  两段 `systemPrompt` 段,以及 web profile 下的两个只读 HTTP 端点;
49
+ 工作区功能默认关闭,因此 `report_lease` 与各工具的 `worktree` 参数默认**不出现**;
49
50
  - **浏览器半**(仅 web profile):会话头部多一个「汇报」标签页 —— 时间线、状态芯片、
50
- 搜索、线程跳转与就地展开的传递路径。
51
+ 搜索、线程跳转与就地展开的传递路径;若开启了工作区,报告行上另有检出标记。
51
52
 
52
53
  ### 兼容性、权限与数据
53
54
 
54
- - **DSH 0.1.5-rc.3 实测可用**(构建产物与该版本的平台模块表对齐)。宿主半的运行时
55
- 外部依赖只有一个:`@deepseek-ai/dsh-tools`,由 profile 提供;浏览器半只 require
56
- `react` 与 `react/jsx-runtime`,其余全部内联。宿主半在 headless profile 里同样可用
57
- (没有 web server 时只是少了那两个端点)。
55
+ - **DSH 0.2.0-rc.2 对齐**(构建产物与该版本的平台模块表逐条比对一致;类型检查、构建与
56
+ 82 条断言均通过)。0.1.5-rc.3 已不再支持:那个 shell 的图标导出带画板尺寸后缀(如
57
+ `IconCopyOutline16`),0.2.0 改成尺寸中立的 `…Regular` / `…Medium` 成对导出,尺寸交给
58
+ `size` prop。宿主半的运行时
59
+ 外部依赖只有一个:`@deepseek-ai/dsh-tools`,由 profile 提供;浏览器半 require
60
+ `react`、`react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives`(面板里的
61
+ 按钮、芯片、悬停、状态点、输入框与正文渲染全部用它,与内置的 Chat / 轨迹视图同一套),
62
+ 其余全部内联。宿主半在 headless profile 里同样可用(没有 web server 时只是少了那两个端点)。
63
+ ⚠️ 于是浏览器半多了一条硬依赖:**shell 的冻结模块表里必须有
64
+ `dsh-client-ui-primitives`**。它自带的对话视图就依赖它,所以缺了它意味着那个 shell
65
+ 本身已经坏了;真缺的话失败模式是「汇报标签页不出现」而不是整个 GUI 崩。
58
66
  - **账本是本机文件**:`$DSH_HOME/report-ledger/`(可用 `DSH_REPORT_LEDGER_ROOT`
59
67
  覆盖),不上传任何地方。
60
68
  - **浏览器读数据走两个 GET 路由**,注册在 DSH 的 web server 上,守卫仅放行 loopback
@@ -70,7 +78,9 @@ dsh --profile web --dump-config # verify: a `report-ledger` row
70
78
  dsh web # restart the profile to load it
71
79
  ```
72
80
 
73
- Tested against DSH 0.1.5-rc.3. The ledger is plain files under
81
+ Aligned with DSH 0.2.0-rc.2 (0.1.5-rc.3 is no longer supported: that shell's icon
82
+ exports carried artboard-size suffixes, which 0.2.0 replaced with size-neutral
83
+ `…Regular` / `…Medium` pairs). The ledger is plain files under
74
84
  `$DSH_HOME/report-ledger/` and nothing leaves the machine. The host half works in
75
85
  any profile; the web profile additionally gets a **Reports** tab in the GUI.
76
86
 
@@ -116,6 +126,8 @@ reports/R-0001.route.jsonl append-only 传递路径,一行一跳
116
126
 
117
127
  选择文件而非私有数据库,是因为账本是一段协作的长期记忆:它必须可被人直接检查、手工修正(写错主题、补一句 hop 说明都不需要迁移),而 front matter 让"只读缩略"不必打开正文。
118
128
 
129
+ front matter 里唯一和工作区有关的字段是**可选的** `worktree` 块(`path` / `branch` / `head` / `repo` / `allocated`);它缺席是常态,老账本不需要任何迁移。跳动作词表里对应三个:`leased`、`released`、`reclaimed`——一个被 `parseHop` 丢弃的动作就是一段无声消失的审计,所以词表是**单一来源**(类型与运行期校验都从同一个常量派生,加动作时漏改一处会被编译器逼出来)。
130
+
119
131
  ## 工具
120
132
 
121
133
  | 工具 | 作用 |
@@ -126,10 +138,12 @@ reports/R-0001.route.jsonl append-only 传递路径,一行一跳
126
138
  | `report_cc` | 抄送给更多代理(不唤醒):让需要知情者持有副本 |
127
139
  | `report_forward` | 转呈下去,或 `mode:"copy"` 作为参考副本分发 |
128
140
  | `report_read` | 读缩略 + 完整传递路径 + 正文(读取本身也记一跳) |
129
- | `report_list` | 只列缩略,可按 `session`、`status`、`task` 过滤,用于纵览长期协作 |
141
+ | `report_list` | 只列缩略,可按 `session`、`status`、`task`、`worktree` 过滤,用于纵览长期协作 |
130
142
  | `report_ack` | 回执,让发送方看到闭环 |
131
143
  | `report_amend` | 修正:改缩略字段或更正正文,仅发起者/共写者可改,改动会被记录 |
132
- | `report_close` | 结案:事情的终结,仅发起者/共写者可关 |
144
+ | `report_close` | 结案:事情的终结,仅发起者/共写者可关;同时**交还**它持有的工作区 |
145
+ | `report_lease` | 认领一个**已存在**的检出(仅工作区功能开启时暴露) |
146
+ | `report_release` | 交还检出占用,检出留在磁盘上原封不动 |
133
147
 
134
148
  ## 修正:记录,而不是抹掉
135
149
 
@@ -180,6 +194,96 @@ authored → closed(note) → reopened → contributed
180
194
 
181
195
  未来读者看到的是过程,而不只是当前终态。
182
196
 
197
+ ## 工作区(可选):一份汇报一个 git 检出
198
+
199
+ 默认**关闭**。打开后,`peer_start` / `report_author` 加 `worktree:true` 会为该汇报分配一个**独立的 git 检出**(`git worktree`),分支名由报告号派生:`dsh/R-0007-<主题 slug>`。主题若是纯中文,slug 退化为空,分支就是 `dsh/R-0007`——合法且仍可反查。
200
+
201
+ 不需要它的部署付出的代价是**零**:没有 git 调用、没有目录,工具里也**不出现** `worktree` 参数。
202
+
203
+ ### 真正被隔离的是什么
204
+
205
+ "独立检出"极容易被读成"独立环境",而两者差距很大,所以边界写清楚:
206
+
207
+ | 面 | 隔离? | 说明 |
208
+ |---|---|---|
209
+ | 工作目录里的文件 | ✅ | 各一份副本,路径级写冲突不存在 |
210
+ | `index`(暂存区) | ✅ | 并发 `add` 不争锁 |
211
+ | `HEAD` / `ORIG_HEAD` / merge 中间态 | ✅ | 每个检出私有 |
212
+ | 分支占用 | ✅ | **唯一的强互斥**:一支分支同时只能被一个检出签出 |
213
+ | `config` | ❌ 共享 | 一个检出改配置,全体被改 |
214
+ | `hooks` | ❌ 共享 | 一个检出装钩子,**此后所有检出的每一次提交都会跑它** |
215
+ | 对象库 / `refs` | ❌ 共享 | 物理只有一处 |
216
+ | 检出之外的一切 | ❌ | 端口、数据库、容器、`%TEMP%`、包管理器全局缓存 |
217
+
218
+ **所以它提供的是"可见、可归属、必须显式和解"的冲突,而不是"没有冲突"。** 两个检出改同一行,两边提交都会成功——冲突只是被推迟到合并时爆发。区别在于那一刻它是**响亮的、带冲突标记的、能归属到两条分支(也就是两个代理)**的,而不是一次静默的覆盖。
219
+
220
+ ### 三个维度,刻意分开
221
+
222
+ | 维度 | 存在哪 | 生命周期 |
223
+ |---|---|---|
224
+ | **绑定** | 报告 front matter 的 `worktree` 字段 | 作者写入,**永不清除**——账本必须一直记得哪些检出是自己建的 |
225
+ | **占用** | 跳流派生(`leased` / `released` / `reclaimed`) | 每次交还、结案或回收时变化 |
226
+ | **归属** | `worktree.allocated` | 创建时定死 |
227
+
228
+ 混起来会立刻出事:把"绑定"当"占用",一份结案报告就会**永远占着**检出;把"占用"当"归属",账本就会去删**别人建的**目录。占用为什么是派生的而不是一个字段——因为账本自己的信条就是这条路(待投递邮箱 `pendingTargets` 同样从跳流派生),而一个字段迟早会和历史对不上。
229
+
230
+ ### 占用就是锁
231
+
232
+ **一支分支同一时刻只能被一份活着的汇报持有。** 裁判查两层,两层都必须过:
233
+
234
+ 1. **git 查现实**——这个检出真的存在吗?真的在这条分支上吗?这条分支真的没被别的检出占着吗?
235
+ 2. **账本查意图**——有没有另一份活着的汇报已经认领了它?
236
+
237
+ 只查 git 会**说不出该找谁**(git 只报路径,而路径不是同事);只查账本会**看不见人手建的检出**。两层对不上时如实报出,绝不静默抹平。
238
+
239
+ ```
240
+ report_lease 认领一个已存在的检出(人手建的、上一份汇报留下的、自己交还过的)
241
+ report_release 交还占用;检出原封不动留在磁盘上,结束的只是"占用"
242
+ report_close 结案 = 自动交还,并按配置决定磁盘处置
243
+ ```
244
+
245
+ `report_lease` 的 `branch` 可以省略:**分支从检出本身读出**,所以存进账本的是 git 报的真实分支与真实 commit,而不是调用方声称的。只有想强制校验时才传它。
246
+
247
+ **没有"接管"策略**:缺席的持有者不会被抢走检出。它必须自己交还或结案——一个可能只是慢的代理,不该因为"没回应"就丢掉工作。被拒绝时会点名持有者**和它的作者们**。
248
+
249
+ ### 两套预算,量的是两件事
250
+
251
+ | 配置 | 量什么 | 满了会怎样 |
252
+ |---|---|---|
253
+ | `maxActive`(默认 4) | 同时被**占用**的检出 | **硬拒绝**,并点名占用者、以及它此刻是否在线 |
254
+ | `maxRetained`(默认 4) | 结案后仍留在磁盘上的检出 | 只有开了 `reclaim` 才清;**脏的一律跳过并报出** |
255
+
256
+ **回收永远不能腾出活跃名额**——可回收的检出按定义就是"占用已经结束"的那些。把两者耦合成一个开关,等于把"把检出从正在干活的代理手里拿走"做成一个配置项。
257
+
258
+ ### 安全轨
259
+
260
+ - **适配器里没有 `--force`。** 没有任何调用方能覆盖 git 的保障:脏检出删不掉、被别人占着的分支抢不走。
261
+ - **结案默认不动磁盘**(`onClose: 'keep'`)。开了 `remove-if-clean` 也只删干净的;被拒绝时**原样抛出 git 的话**,而不是吞掉。
262
+ - **删除的授权来自报告**:只有 `allocated: true` 的才可能被回收,人手建的目录账本永不碰。
263
+ - **人手删掉的检出会被一次性发现**:下次分配时账本记一笔"已经不在磁盘上",此后不再重复检查——而不是每次重新审视、并被误报成"有未提交改动"。
264
+ - **回收是终态**:`reclaimed` 与 `released` 是两个状态。账本清掉一个检出——无论是超预算回收,还是结案时按 `remove-if-clean` 移除——都会记下 `reclaimed`;此后它不再被当成候选,也不会被误报成"异常消失"。这一条不是洁癖:一次**故意**的移除如果读起来像异常,那条警告就会在推荐流程上每次都响,而读者会学会忽略它。
265
+
266
+ ### 配置
267
+
268
+ ```yaml
269
+ worktree:
270
+ enabled: true # 总开关,默认 false
271
+ root: D:/somewhere # 检出根目录;默认在仓库的兄弟目录:<仓库父目录>/.dsh-worktrees/<仓库名>/
272
+ onClose: keep # 结案处置:keep(默认)| remove-if-clean
273
+ maxActive: 4 # 同时被占用的上限
274
+ maxRetained: 4 # 结案后留在磁盘上的上限
275
+ reclaim: never # never(默认)| oldest-closed:超限时清最老的、干净的、账本自建的
276
+ relativePaths: false # 用相对路径写 gitfile
277
+ ```
278
+
279
+ `relativePaths` 不是纯外观:它启用 `extensions.relativeWorktrees`,把 `core.repositoryformatversion` 抬到 1,**旧版 git 会直接拒绝访问该仓库**,所以默认关闭。
280
+
281
+ **子模块是硬闸门**:仓库里有 `.gitmodules` 时分配被拒绝。git 自己的手册说多检出仍属实验性、子模块支持不完整,且含子模块的链接工作区**连 `move` 都不行**——这里选择拒绝而不是提醒。
282
+
283
+ ### 在界面里
284
+
285
+ 报告行上会出现该检出的标记(分支名),**色调就是占用状态**:绿色 = 正在被占用,蓝/灰 = 已交还或已被回收。点一下即以它为条件过滤——与任务标记同一套交互,复用搜索框,不新增第二套过滤机制。
286
+
183
287
  ## 提示词:协议与伙伴契约
184
288
 
185
289
  插件注入**两段** `systemPrompt.section`,这个拆分是有意的:
@@ -189,7 +293,7 @@ authored → closed(note) → reopened → contributed
189
293
  | 协议 | `plugin:report-ledger` / 200 | 静态字符串 | 对所有 agent **逐字节相同**,因此是各 scope 里稳定的提示词前缀(KV cache 友好) |
190
294
  | 伙伴契约 | `plugin:report-ledger:partnership` / 201 | 按 assembly 求值的函数 | 契约要对两种读者说不同的话 |
191
295
 
192
- **协议段**说明账本是什么、八个工具各做什么,以及两条会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成。
296
+ **协议段**说明账本是什么、各工具做什么(十四个;工作区功能关闭时不暴露 `report_lease`),以及会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成;工作区若启用,独立的是哪些、共享的是哪些。
193
297
 
194
298
  **伙伴契约段**分两种读者:
195
299
 
@@ -205,7 +309,7 @@ authored → closed(note) → reopened → contributed
205
309
  页面渲染**一条自上而下的时间线**,把两类事件按时间合并:
206
310
 
207
311
  - **会话分叉**:子树里每个会话一行,按深度缩进,标注「子代理 / 在线」;
208
- - **汇报出现**:每份汇报一张缩略卡(状态、主题、收发、抄送、跳数、最后动作),点击就地展开**完整传递路径 + 正文 + 仍在等待送达的收件人**。
312
+ - **汇报出现**:每份汇报一张缩略卡(状态、主题、收发、抄送、跳数、最后动作),点击就地展开**完整传递路径 + 正文 + 仍在等待送达的收件人**。鼠标点整行即可;键盘走左侧箭头——那是一个真按钮,带 `aria-expanded` 与 `aria-controls`,名字是「展开 R-0001 / 收起 R-0001」。
209
313
 
210
314
  ### 筛选与搜索
211
315
 
@@ -219,16 +323,178 @@ authored → closed(note) → reopened → contributed
219
323
 
220
324
  **状态筛选只作用于汇报;搜索作用于每一行。** 会话没有生命周期状态,所以状态芯片不会隐藏会话——时间线的骨架始终在,缩进也就始终有意义(汇报的 depth 来自它的发起会话,与会话行是否被渲染无关)。
221
325
 
222
- **空状态分两种。** 「这个会话树下还没有汇报」与「没有符合当前筛选的汇报」是不同的话,混用会让人以为账本坏了。
326
+ **空状态分两种。** 「这个会话树下还没有汇报」与「没有符合当前筛选的汇报」是不同的话,混用会让人以为账本坏了。判定下沉在 `timeline-model.ts` 的 `emptyState()` 里,由测试钉住:前者按「载荷里有没有汇报」判,后者按「筛选后还剩几行**汇报**」判——只数汇报行,因为会话行按设计不受状态芯片影响,把它们算进去会让一棵满是会话的树自称「你把结果筛没了」。账本为空时永远说第一种:此时筛选不可能是原因,归咎于它只会让人去找一个自己从没设过的筛选。
327
+
328
+ ⚠️ 这里踩过一次:初版把「树下还没有汇报」挂在「一行都没有」上,而只要有会话就至少有那一行会话——于是这句话永远不出现,空账本渲染成一片没有任何解释的空白。**空状态的判据必须落在载荷上,不能落在渲染出来的行上。**
223
329
 
224
330
  行构建与筛选逻辑都在 `src/client/timeline-model.ts` —— 一个**不依赖 React 与 DOM 的纯模块**。这样"哪些行出现、搜索匹配什么、芯片怎么计数"这些用户直接感受到的决定能用确定性测试钉住,而不必靠看浏览器;视图只负责渲染。
225
331
 
332
+ ### 缩略卡上的身份与用词
333
+
334
+ **id 缩短,全值在悬停里。** 会话 id 是 36 字符,而一份汇报的收件人是一串 id,于是未缩写的缩略卡几乎全是 uuid,人能用的信息接近于零。所以**密集行**显示前 8 位(`01edba72`),悬停给出「会话标题 · 完整 id」;**详情面板保留完整 id**,因为那是有人要复制 id 的地方,而缩略卡不是。会话行也带上自己的短 id,于是卡片上的 `from=`/`to=` 能读回它属于哪棵树。实测副作用是好的:改短之后 7 张卡的 meta 行**没有一行再触发省略号**——`overflow: hidden` + `text-overflow: ellipsis` 从日常现象退回成安全网;而这层安全网仍然必要,因为收件人数量没有上限,宁可截断也不能让一行把整个标签页顶宽。
335
+
336
+ **不拿会话标题当行标签**,尽管它更「像人话」:`fromName` 取自会话标题,而标题是会话的第一句提示词——它**没有长度上限**,有时比它替换掉的 id 还长(本仓库里的子代理标题正是 `You are doing READ-ONLY research`,四个子代理一模一样)。把定长但不可读的 id 换成不定长且会重复的标题,只是把一种不可读换成另一种,所以名字放在悬停里、放在会话行上,不放在密集行里。
337
+
338
+ **词表只有一份。** 卡片上的状态芯片复用工具栏筛选芯片**同一批键**(`进行中 / 已回执 / 已结案`):它们本来就在命名同一个状态,给它们两套词(卡上写 `open`、工具栏写 `进行中`)等于把建映射的活推给读者,而那个映射本身就是缺陷。跳动作同理,12 个动作各有词(`发起 / 共写 / 主送 / 送达 / 抄送 / 转呈 / 副本 / 阅读 / 回执 / 修正 / 结案 / 重开`),由 `HOP_LABEL` 对动作全集取 `Record` 保证「新增一个动作不可能没有词」。**账本文件与模型看到的仍是原文 token**——只有人的视图说人话。
339
+
340
+ ### 用平台的组件库,而不是手搓
341
+
342
+ 面板里**每一个可见控件**都来自 shell 的 `@deepseek-ai/dsh-client-ui-primitives`(它在 shell 的冻结模块表里,`tsdown.config.ts` 早已把它列为 external):`Button`(刷新、清除、时间基准、线程跳转)、`Pill`(状态筛选芯片、任务快捷筛选——它给 `onClick` 就是真按钮,不给就是纯标签)、`Tag`(状态与「子代理 / 在线」)、`Tooltip`(短 id 与各动作的悬停说明)、`StateDot`(在线会话是**动画的** `ongoing`、待送达是 `warning`、错误是 `error`)、`Input`(搜索)、`MarkdownText`(正文)、以及图标(刷新、折叠箭头、复制、勾选)。手搓的颜色、hover、focus、暗色适配因此全部不再由本插件负责。
343
+
344
+ **合法取值是读 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
+
346
+ **组件不吃 style,吃 `className`,而且落在它自己的 wrapper 上。** 于是本插件唯一自己写的 CSS 是一行布局:把工具栏的搜索框撑满(`client/styles.ts`)。规则挂**我们自己的类名**,绝不碰 hash 类名——那是会随 shell 版本变的东西。
347
+
348
+ **顺手换来的一个能力**:账本路径旁边多了复制按钮,走 shell 的 `writeClipboard`(而不是 `navigator.clipboard`),这样它继承的是整个 GUI 都在用的那条降级链。
349
+
350
+ ⚠️ **汇报行故意没有换成 `DisclosureRow`**,尽管它正好是「一行摘要 + 展开正文」的形状,而且已发布的视图用了 8 次。原因写在 `ReportsView` 里:它的两种模式各和已经提交的东西冲突一处。`expandOnRowClick: true` 时整行是 `role="button"`——行内的任务芯片立刻变成嵌套交互内容,而且可访问名由整棵子树计算(读屏会把整行 id 列表念一遍)。`expandOnRowClick: false` 时前导按钮换成一个库内部的按钮,它的可访问名由那个图标决定,而**展开后图标会被替换掉**,名字随之消失;同时整行点击也没有了。所以行**外壳**保留自己的实现(普通容器承担鼠标便捷点击、具名展开按钮承担键盘路径、面板是兄弟节点所以点正文不会收起),**里面的控件全部换成库组件**。要用 `DisclosureRow` 的话,代价是把「点任务标签即分组」降级成展开后再点——这是个产品取舍,不是技术限制。
351
+
352
+ ### 拓扑总览:竖向泳道
353
+
354
+ 列表上方是一张**竖向泳道图**:一条泳道 = 一个会话(列),时间向下(行),每一份汇报画成发起者泳道上的一个节点,它的传递路径画成跨泳道的边。
355
+
356
+ (**这一版已不在标签页里渲染**:卡片画布接手了图表位,见下面「下一版拓扑」;`TopologyView.tsx` 与 `topology-model.ts` 保留在仓库里、不再被引入,因此不进 bundle,它的几何仍由 `scripts/topology-check.ts` 钉着。下面这段记的是它验证过的东西——结论全部沿用,换掉的只有"节点不承载内容"这一件事。)
357
+
358
+ ```
359
+ 泳道(会话, 树序) → afcbce3a │ 01edba72 │ 05286f38 │ … │ 树外
360
+ 时间 ↓ │ │ │ │
361
+ 19:00 ●R-0001 ─────▶──▶──▶┄┄▶ (主送实线、抄送点线)
362
+ 20:30 └╌╌●R-0002 ──▶ (线程竖向、回执向回)
363
+ 19:45 ●R-0003 ┄┄┄┄┄┄┄┄┄┄┄┄▶
364
+ ```
365
+
366
+ **为什么是竖向、为什么不需要图引擎。** 这个标签页本来就纵向滚动,时间向下与下面列表的阅读方向一致;而布局**不需要任何图算法**——泳道序就是子树 DFS 序(列表缩进用的同一个序),行序就是时间序(列表排序用的同一个序),两个 rank 都是现成的。通用引擎要解的是**交叉最小化与端口路由**,是这份数据没有的问题,却要为此付出:`elkjs` 是 EPL-2.0/GPL 且解包 8 MB,`d3-dag` 虽是 MIT 但为最优交叉最小化拖进一个线性规划求解器,`@xyflow/react` 1.2 MB 且自带一套样式表。所以这一版**零新增依赖**,几何由 `src/client/topology-model.ts` 这个纯模块给出。
367
+
368
+ **它是什么、不是什么。** 它是**总览**,不是第二个阅读面:节点点击后**滚到列表里那张卡片并展开**——正文、传递路径、待送达仍在同一个地方读。这样图不必处理 4000 字的正文,也不承担"键盘/读屏唯一入口"的责任:列表一直在,图只是加在它上面。
369
+
370
+ **树外会话有一条共享泳道。** 被 cc 进来的、或发起者在别的树里的汇报,如果没有这条泳道就无处落点;它们全部落在最后一列 `树外`。
371
+
372
+ **这条横向滚动是刻意的。** 视图自己有一个受控的滚动框(宽出即可横向滚动、高限 46vh),与之前那个"内容撑破整个标签页、滚动条只在最底部够得着"的意外滚动条不同:这里是**包在一个框里**的。默认几何(泳道 120px、沟槽 84px)是照"八条泳道刚好放进一个标签页"定的(实测 1108px,无需横向滚动)。
373
+
374
+ **布局被 34 条断言钉住**:泳道序等于子树序、节点落在发起者泳道、边落在收件人泳道且同一行、重复收件人只画一条、发给自己的不画、树外落点归到共享泳道、线程边只在线程两端都被画出时才存在、空输入不产生 NaN、同一输入给出逐字节相同的几何(见 `scripts/topology-check.ts`)。
375
+
376
+ **图例必需,逐边文字不必需。** 四条线型如果没人解释,读者只能猜——所以标题旁有图例,而且它的样例**用的就是画线的同一批 class**(`edgeClass()`),改了线的样子不可能留下一个说谎的图例。但**每条边都挂文字是重复的**:线型已经编码了主送/抄送/共写。所以文字只在**悬停某一份汇报时**出现,回答的是"我正在追的这条路径是什么关系"。这也正是 Mermaid 泳道文档里 "Label Cross-Lane Handoffs" 想说的那件事,只是它的载体是线型 + 悬停,不是常驻标签。
377
+
378
+ **线程走泳道之间的缝隙。** 父子连线跨行,直接画曲线会穿过中间的行与节点;所以模型为它算出 `viaX`——**一条泳道边界**,节点都居中在泳道里,边界离任何节点都有半个泳道远,连线因此完全不碰节点。这条规则也进了断言(边界必须严格落在两列中心之间、且从不落在任何一列的中心上)。
379
+
380
+ ### 规模:布局是免费的,渲染才是成本
381
+
382
+ 在一份 300 份汇报的账本上量过(隔离实例,真实浏览器):
383
+
384
+ | 量的是什么 | 结果 |
385
+ |---|---|
386
+ | **纯布局**(500 泳道 × 5000 汇报 × 7916 条边) | **3.4 ms**(20 次中位数)——布局永远不是瓶颈 |
387
+ | 渲染元素(300 汇报,改之前) | 5469 个:838 条边各自一个 `<path>` + 838 个箭头 `<polygon>` + 300 条分隔线… |
388
+ | 悬停一次(改之前) | **89 ms** —— 每次 mouseenter 要改 831 个 `<g>` 的透明度 |
389
+
390
+ 两处修法都有实测回报:
391
+
392
+ 1. **同类边合并成一条 `<path>`,箭头改用 SVG `marker`**——边本来就不可点击,一条边一个元素只买到"每次重绘一个形状"。箭头是路径的装饰而不是节点,交给 `marker` 后额外元素为零。
393
+ 2. **淡出只作用于"边的那一层",绝不走后代选择器。** 这是最有价值的一条:单独翻写一个被**后代选择器**匹配的属性(`[data-hot] … :not(…)`)本身就要 **35 ms**,因为浏览器要为整棵子树重算样式,**哪怕最终没有任何可见变化**(我用注入 CSS 把结果改成 `!important` 覆盖也降不下来——覆盖只改结果,不减重算)。所以节点干脆不淡出:追踪一条路径时该退到后面的是**关系**,不是参与者。
394
+
395
+ 结果:元素 **5469 → 2689**、边路径 **838 → 10**、箭头多边形 **834 → 0**、悬停 **89 → 44 ms**(最好的一次 16–19 ms,即一帧)。
396
+
397
+ 3. **窗口化**:只渲染滚动框里真正看得见的那几行(上下各留 4 行 overscan)。300 份汇报时任意时刻只渲染 14–20 行,元素 **2689 → 265**、悬停 **44 → 32 ms**——而 32 ms 就是**测法的地板**(每次采样等两帧,60 Hz 下约 33 ms),也就是已经没有可测的开销了。四个滚动位置都验过窗口覆盖视口(顶部/中部/底部/偏后),粘性表头在位,没有空白带。
398
+
399
+ ⚠️ **窗口化引入的每个派生列表都必须依赖窗口。** 我把节点列表的数据源从 `layout.nodes` 换成 `shownNodes`,却忘了把它加进 `useMemo` 的依赖数组——结果是**边与时间轴跟着滚动,只有节点不动**,看起来像"滚了但内容没换"。这类 bug 在浏览器里一眼可见(四种滚动位置一测就抓到),而七套断言全都发现不了:纯模型没有错,错的是"React 有没有重算"。所以窗口化的断言只覆盖"哪些行该在窗口里",覆盖不到这一层,这一点要说清楚。
400
+
401
+ ### 下一版拓扑:卡片画布(参考与实测)
402
+
403
+ 泳道版证明了"关系和归属可以画出来",但它有个致命弱点:**节点是芯片,不承载内容**——一份汇报在图里只剩一个 `R-0001`。下一版改成卡片图,两个决定已经定了:
404
+
405
+ - **会话 = 框**(蓝图里 Comment 那种:带标题、底色,可整体折叠),归属靠**包含**表达,不靠位置;
406
+ - **全 canvas 渲染**(不是 SVG、不是 DOM)。
407
+
408
+ **卡片图的设计**:帧内按时间堆叠汇报卡片;线有方向与语义——主送实线(卡片出 → 对方帧的收件口)、抄送点线、共写虚线(共写者帧 → 卡片入)、线程蓝线(父卡下缘 → 子卡上缘)。**三档 LOD**:完整卡(≥0.8)→ 紧凑卡(0.4–0.8)→ 色条(<0.4),配矩形剔除。
409
+
410
+ ⚠️ **一条硬规矩:卡片尺寸是 LOD 常量,绝不由文字度量决定。** 否则布局就依赖 canvas 度量、变成不可测的东西,而"缩放到全局时有多少个绘制对象"恰恰是这套设计唯一站得住的标准。文字在**绘制时**裁剪/省略。
411
+
412
+ **参考里真正可用的部分**
413
+
414
+ | 参考 | 学到什么 |
415
+ |---|---|
416
+ | Unreal **蓝图** | 卡片节点的解剖(标题栏 + 引脚 + 有类型的线)与 Comment 框做分组。(⚠️ Epic 的文档页是 JS 渲染的,抓不到正文——这部分是通行认知,不是引用) |
417
+ | **draw.io / maxGraph** | ① 它**用的是 SVG 不是 canvas**:`packages/core/src/view/canvas/` 下只有 `AbstractCanvas2D`(13.6 KB)、`SvgCanvas2D`(47.6 KB)、`XmlCanvas2D`(28.2 KB,是导出不是绘制)——所以"照 draw.io 做"**不支持**"改用 canvas"。② 真正值钱的是架构:**画家抽象**(`state` + `save/restore` + 变换 + `rect/roundrect/text/begin/moveTo/quadTo/curveTo/fill/stroke`),形状只管画模型坐标。③ `begin()` 建**一个** `<path>`,之后所有操作累积、最后一次 `setAttribute('d', …)` 提交——正是我这边实测出来的"合并路径"(838 条边 → 10 条)。④ 坐标统一走 `(x + dx) * scale` 并**取整**,奇数描边宽补 `translate(0.5, 0.5)` 求清晰。⑤ 文字**不跟几何一起画**:`foreignObject` + 真 `<div>` 交给浏览器排版,并留住节点引用让 `updateText` 只挪位置。⑥ 命中的容差靠克隆一个更粗的透明描边。 |
418
+ | **React Flow** 的性能文档 | node-graph 在规模上的共识:只渲染可见元素、memo 化自定义节点、别让视口变化触发全量重渲染。([reactflow.dev/learn/advanced-use/performance](https://reactflow.dev/learn/advanced-use/performance)) |
419
+ | Excalidraw | **双画布**:静态层与交互层分开。(这条来自一篇二次分析而非其源码,标注待确认;但理由与我们的实测一致——悬停曾是我们最大的热点) |
420
+
421
+ **为什么一个图库都不引**(实测与查证,不是好恶)
422
+
423
+ | 候选 | 事实 | 判定 |
424
+ |---|---|---|
425
+ | **konva** | MIT、**零运行时依赖**、canvas,且自带文字 wrap/ellipsis 与命中检测 | 用我们的真实用法建了探针:**+491 KB raw / +115 KB gzip**,而当前客户端半只有 89 KB raw——**5.6 倍**。换来的实际只有换行与命中两件小事(约 65 行),而**保留式场景图对我们是负资产**:架构是"纯模型 → 绘制一遍",引入场景图等于维护第二份可变几何,正是本仓库已经踩过的那类 bug(边跟着滚、节点没动)。**不引。** |
426
+ | **@antv/g6** | MIT,概念上最省(combo 就是"框",自带布局与小地图) | **11 个运行时依赖**/解包 7.6 MB/自定门槛 400 KB gzip;且 `@antv/layout` 有 **WASM 动态分块**路径——单文件插件 bundle 里动态分块会 404,与当初否掉 Mermaid、elkjs 是同一个坑。**不引。** |
427
+ | cytoscape | MIT、零依赖、canvas | 节点模型是"形状 + 标签",**做不了多行卡片**,正是新设计最在意的部分。**不引。** |
428
+ | maxGraph(draw.io) | Apache-2.0,路由/端口/泳道形状齐全 | 渲染是 **SVG**,与"全 canvas"冲突;只取几何/路由又得半个大库。**不引,但抄它的画家抽象。** |
429
+ | Excalidraw / tldraw | — | 前者是编辑器应用(渲染器不对外复用),后者生产使用需要 license key。**不引。** |
430
+
431
+ **Step 0 已落地:`src/client/cardgraph-model.ts`(96 条断言)**
432
+
433
+ 先做模型、再写画布,因为这份设计要证明的全是数字:哪只帧装哪张卡、线从哪条边走、两条线落在同一条帧边上时各自落在哪、一个视口能看见什么。模型里已经钉死的规则:
434
+
435
+ | 规则 | 是什么 | 为什么 |
436
+ |---|---|---|
437
+ | **列 = 子树深度** | 同深度的帧在同一列里自上而下堆,列序即 DFS 序 | 父亲永远在孩子的左边,主送/线程天然是短横线;不需要 rank 计算 |
438
+ | **帧高 = 标题栏 + 内边距 + 卡片堆** | 空帧也保底一个最小高度 | 空会话仍然读得出一只框,而不是消失 |
439
+ | **卡片刻度 = LOD 常量** | 文字只影响绘制,不影响几何 | 见上面的硬规矩;实测断言:400 字主题与 3 字主题拿到**同一个矩形** |
440
+ | **输入顺序无关** | 卡片按 `created` 排,平局用 report id;连边也按卡片序走 | "同一本账,同一张图",打乱输入重建 byte 相同 |
441
+ | **收件口均分** | 同一帧同一边的线,按**远端 y** 排序后在卡片带内等分 | 两条线永不落在同一像素,且不会在口子上互相交叉 |
442
+ | **同列走沟槽** | 同列两只帧之间的线,从卡片侧边出去、沿栏间沟槽、再进对方同侧 | 直线会**横穿它自己落地的那只框**(这是实测 dump 里发现的,不是设想),读起来像"这条线属于那只框" |
443
+ | **跨列是直线** | 跨列的线直接连,可能穿过中间的帧 | 这是这一版接受的代价:细线 + 箭头叠在框上,和 draw.io 一样。真正的绕障要图算法,正是我们不要的东西 |
444
+ | **矩形剔除取代窗口化** | 一切带 `bounds` 的东西(帧/卡片/线)用同一个 `cullByBounds` | 自由画布没有"行"可数;且**两端都在屏幕外、中段穿过视口的线必须留下**——泳道版为这条踩过坑,断言里复现了它 |
445
+ | **视口反变换** | `worldViewport` 把 pan/zoom 换成绘制坐标里的可见矩形 | 坏掉的 `scale`(0/NaN)退化成 1,绝不产生无限或反向矩形 |
446
+
447
+ 现在**没有任何视图引入这个模块**,所以它对运行时是零成本:重建前后 `lib/client.js` 都是 **89,360 B**(实测,未变)。它由 `scripts/cardgraph-check.ts`(96 条断言,与另外六套一起进 `pnpm test`)钉住。
448
+
449
+ **Step 1 已落地:`src/client/cardgraph-painter.ts`(78 条断言)**
450
+
451
+ 画家只认一个**结构化上下文** `PaintContext`——十来个方法(填充、路径、文字、一个变换),真 canvas 天然满足它,测试里换成一只**记录器**。于是"这条线被淡出了、那只框没有""画的是虚线还是实线""箭头落在哪个点"全都成了断言,不用开浏览器。这也是 draw.io 的画家抽象真正值钱的地方:换成 SVG 只是再实现一遍这个接口。
452
+
453
+ | 决定 | 是什么 |
454
+ |---|---|
455
+ | **绘制顺序:帧 → 线 → 卡片** | 线永远压在它穿过的框上(不会消失),而卡片的文字永远不会被线穿过 |
456
+ | **文字只在这里量** | `foldText`/`elide` 按 `measure` 折行:Latin 能在空格处断就断,中文按字断;按 (字体, 文本) 缓存,因为画布上每次测量都是一次同步排版 |
457
+ | **几何一点不看文字** | 断言把测量宽度从 1 改到 40:画出来的矩形与线段**逐字节相同**,而文字确实变了 |
458
+ | **主题走 token** | 15 个颜色槽 + 4 个字体 token(每个自带 size/weight/line-height/family),全部有兜底;重读靠一只**金丝雀 token**(页面底色)——它没动就不重读,于是每次重绘只多一次属性读取 |
459
+ | **状态与线型同源** | 卡片左缘色条、状态文字、四种线的虚实都取自同一张表,和图例、列表的 `Tag` 语气一致 |
460
+
461
+ ⚠️ **已知未做:坐标不做像素对齐。** draw.io 是在变换之后取整的,因为它的画布是 1:1 屏幕空间;我们的画布是缩放过的,要取整就得逐点换算到屏幕空间。先按抗锯齿走,等实测说糊了再补——这条留在 README 而不是留在代码注释里,是因为它需要一个浏览器里的判断。
462
+
463
+ **Step 2 已落地:`src/client/CardgraphView.tsx`——在真实浏览器里逐条量过**
464
+
465
+ 标签页现在渲染卡片画布。两只画布(静态 + 交互)、平移缩放、命中、LOD 跟随、点开详情全部做完,并且在**隔离实例 + 真实浏览器**里逐条验证(不是推理):
466
+
467
+ | 验的是什么 | 结果 |
468
+ |---|---|
469
+ | 真的画出来了吗 | 底图 `946×420`、100% 不透明;像素里数到文字(8,976 个暗像素)、橙/绿状态色、蓝色线程——四种线型都落了墨 |
470
+ | **悬停不重绘底图** | 悬停前后底图墨量**完全相同**(397,320);交互层从 0 变成有内容。这就是两层的意义 |
471
+ | 悬停的代价 | 中位 **32.6 ms**,而每次采样等两帧的地板是 ~33 ms(60 Hz)——也就是**测不到开销**,与泳道版当初撞到的同一条地板 |
472
+ | 点击卡片 | 打开下面列表里的详情(`手工核对几何 dump` 出现在 DOM 里)——图表不另开阅读面 |
473
+ | 拖拽 | 平移了(按钮的 `left` 变了),**且没有触发打开**——拖动与点击靠 4px 阈值分开 |
474
+ | 滚轮 | 以光标为中心缩放(80% → 100% → 80%),`window.scrollY` 始终为 **0**——页面不会在缩放下滚动 |
475
+ | LOD 真的跟着缩放 | 卡片档 **8,976** 暗像素 → 芯片档(26%)**301**(只剩帧标题)→ 125% 时 **13,200** |
476
+ | 无障碍 | 7 张可见卡片各有一个**真按钮**(透明、带 `aria-label`),键盘/读屏可达;列表仍是完整路径 |
477
+ | 热重载 | 重建 `lib/client.js` 后浏览器**自己换掉**了插件(页内不刷新),这就是改完立刻能看到的原因 |
478
+
479
+ **三处是看了截图才改的**(不是想出来的):
480
+
481
+ 1. **首屏不是"适应窗口"。** 一开始沿用 fit,结果是 **57%**——落在紧凑档,读者第一眼看到的是一堵单行卡片墙,而卡片画布的卖点恰是被省略掉的主题。改成**按宽度适配、且不低于卡片档**(`clamp(…, 0.8, 1)`):暗像素 **1,743 → 8,976**,帧、列与可读卡片同时在场。"适应窗口"按钮仍然保留,用来看全貌。
482
+ 2. **遮罩从 0.18 改到 0.55。** 第一版照搬泳道版的淡出值,截图一看:其它帧、标题、卡片**全都读不出来了**——追一条路径的代价是把整个上下文赔进去。泳道版自己早就得出过同一条结论的另一半(*该退到后面的是关系,不是参与者*),而画布遮罩比逐个形状淡出更粗暴,所以必须更轻。现在聚焦路径在顶上全强度重绘,其余保持可读。
483
+ 3. **计数说的是"8 个框"而不是"8 个会话"。** 共享的"树外"帧也是一个框,把它算成会话就是在说假话。
484
+
485
+ ⚠️ **顺带修掉一个自己在代码里埋的雷**:滚轮处理原先在 `setZoom` 的 updater 里调 `setOffset`——那是"在 updater 里做副作用",React 有权把 updater 调用两次,于是每一格滚轮位移会被应用两遍、缩放会从光标下漂走。改成用 ref 读当前变换、在 updater 外面算。
486
+
487
+ 代价:整个卡片画布(模型 + 画家 + 视图)让客户端半从 **89,360 B → 114,955 B**(gzip 24.86 → 32.17 kB),**+7.3 kB gzip**——而当初被否掉的 konva 光是库自己就要 **+115 kB gzip**。泳道版的 DOM 视图随之下线,这部分是被它自己腾出来的地方抵掉的。
488
+
489
+ **留下什么、重写什么**:泳道序(成为帧的排列序)、树外泳道(成为"树外"帧)、四种边的语义、窗口化(升级为矩形剔除)、单层淡出、合并路径、marker 箭头、图例、悬停文字——**全部保留**;泳道的"列 + 行 + 芯片"重写成"帧 + 卡片 + 线"。列表、详情面板、过滤、轮询完全不动。
490
+
226
491
  ### 线程、时间基准与阅读上限
227
492
 
228
493
  - **线程跳转**:详情面板显示该汇报的**上溯**(`parent`)与**下递**(`children`),点任意编号即跳到那一份。链接可以指向**当前树之外**的汇报(它属于另一条线),此时详情仍会打开——`report` 端点按账本范围而非子树范围查询——并明确提示"不在当前会话树的列表里"。
229
494
  - **时间基准**:一键在「按创建 / 按最近活动」之间切换。会话始终按创建时间;切换只影响汇报在时间轴上的落点。
230
- - **任务标签即分组**:卡片上的 `task` 标签**可点击**——点它就把搜索框设为该标签,于是同一次协作(可能横跨多条会话树)被拉到一起。这是刻意的实现选择:**复用已有的搜索,不引入第二套筛选状态**;`report_list({task})` 在工具侧提供同一维度的分组,并把该任务的 open/acked/closed 统计一并返回。
231
- - **正文明限**:面板只显示正文开头(>4000 字时截断并提示),与 `report_read` 给模型的上限**保持一致**——让人类视图与模型视图被同样地约束,双方都不会对对方看到的范围感到意外。
495
+ - **任务标签即分组**:卡片上的 `task` 标签**可点击**——点它就把搜索框设为该标签,于是同一次协作(可能横跨多条会话树)被拉到一起。这是刻意的实现选择:**复用已有的搜索,不引入第二套筛选状态**;`report_list({task})` 在工具侧提供同一维度的分组,并把该任务的 open/acked/closed 统计一并返回。点它还**把状态芯片清回「全部」**:它的提示语承诺的是「只看任务 X」,而只要还有一个状态芯片在收窄,这句话就是假的——实测先筛「已结案」再点任务芯片,原本会得到一句「显示 0/7」,让人以为这个任务没有汇报。
496
+ - **正文明限**:面板只显示正文开头(>4000 字时截断并提示),与 `report_read` 给模型的上限**保持一致**——让人类视图与模型视图被同样地约束,双方都不会对对方看到的范围感到意外。**范围一致,排版不同**:正文用 shell 自己的 `MarkdownText` 渲染,所以标题、列表、表格、代码块在这里与在对话标签页里长得一样(代码块还带「复制」按钮),而模型拿到的仍是同一段纯文本。**面板里没有第二个滚动条**:正文已经被这个上限约束住了,再套一个 320px 的内层滚动只会在时间线自己的滚动之上抢滚轮;面板随内容变高,滚动统一交给外层。
497
+ - **截断 Markdown 不等于截断文本**:切点落在代码块中间会留下一个没有闭合的围栏,等于把「代码块到哪里结束」交给渲染器去猜。实测这个渲染器猜得对(切在围栏中间会渲染成一个正常闭合的代码块,没有尾随痕迹),所以 `closeOpenFence()` 是**便宜的保险而不是修一个看得见的坏**——渲染器是插件不拥有的平台模块,它的宽容不是契约,补一个围栏让输出在任何渲染器下都是合法 Markdown。它共 5 条断言,且只在正文真被截断时才跑。
232
498
  - **两种"看不见"分得很清**:汇报**不在树里** vs 汇报**被当前筛选隐藏**——两组措辞不同。把后者说成"可能属于另一条线"是错的,所以两种情形各有各的话。
233
499
 
234
500
  ### 数据通路
@@ -237,16 +503,25 @@ authored → closed(note) → reopened → contributed
237
503
 
238
504
  | 端点 | 返回 |
239
505
  |---|---|
240
- | `GET /api/report-ledger/timeline?root=<sessionId>` | 该会话的**递归子树**(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略 |
506
+ | `GET /api/report-ledger/timeline?root=<sessionId>` | 该会话的**递归子树**(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略 + 检出游道 |
241
507
  | `GET /api/report-ledger/report?id=<R-0001>` | 单份汇报的缩略 + 完整路径 + 待送达集合 + 正文 |
508
+ | `GET /api/report-ledger/worktrees` | **维护视图**:账本知道的每个检出及其磁盘实况、两套预算的当前用量、git 有而账本不知道的检出、悬空条目与警告 |
509
+
510
+ 第三个端点是无参数的只读视图,**无论工作区功能开没开都注册**——操作者问"外面有没有东西",答案即使"没有,而且分配是关的"也比 404 有用(404 与打错路径无法区分)。它**只报告、绝不修复**:一个被手工搬走的检出从它自己那侧照常可用,而 git 认为它悬空,所以一条自动化的 `prune` 会毁掉活着的工作;把这个判断留给看着事实的人。
511
+
512
+ 它专门呈现两件账本**结构上看不到**的事,因为它们是这个不对称设计必须付的代价:**git 有、而没有任何报告绑定的检出**(人手建的),以及**已被手工删掉、git 报为悬空的条目**。两者都会生成一句警告,并各自给出去处(前者可以 `report_lease` 认领,后者需要 `git worktree repair` 或显式 `prune`)。
242
513
 
243
514
  子树由 `ctx.sessionQuery.listSessions()` 的 `parentSession` 链走出,标题只对**子树内存活的会话**查询(长寿命部署里语料远大于一次协作,而标题是装饰、树不是)。汇报按"子树任一成员参与过它的路径"过滤(发起、主送、抄送、共写),因此这是**协作账本**而不是全库倾倒。
244
515
 
245
- 为什么不用 typert 生成的 Remote:那需要构建期代码生成。第三方插件的通行做法是自建同源、仅限 loopback 的 JSON 通道,本插件照此实现。**两半共用 `src/shared/wire.ts` 的类型**——该文件只有 `export type`,会被完全擦除,所以浏览器 bundle 不可能内联宿主代码(构建后已核验:外部依赖仅 `react` 与 `react/jsx-runtime`,node 内置模块与 yaml 均为 0 命中)。
516
+ 为什么不用 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
+
518
+ 那个平台模块的类型是**声明出来的,不是导入的**(`src/client/platform.d.ts`):它只存在于 shell 自己的 bundle 里,不在 profile 的 `node_modules` 层——而 `tsconfig` 的 `@deepseek-ai/*` 正映射到那一层。这与 `client/index.ts` 给 `slots` / `locale` 写结构化接口是同一个做法,理由也一样:**断言我们实际调用的形状,不多声明一个字段**(多声明的字段在运行时会被静默忽略)。
519
+
520
+ **视图会自己跟上。** 账本是别的 agent 在后台写的,所以只取一次的快照会在最需要它的那一刻过期:打开期间每 10 秒重取一次时间线,页面不可见时跳过(后台标签页零成本),切回该浏览器标签页时立刻补一次。「刷新」按钮保留,并且**只有它**会连已打开的详情面板一起重取——轮询刻意不碰详情,否则正文每 10 秒闪回一次「正在读取传递路径」。两个计数器(`timelineNonce` / `detailNonce`)就是为这个区分存在的。
246
521
 
247
522
  ### 守卫与信任假设
248
523
 
249
- 两个端点都是 GET、只读、无写入面(所有变更仍只走模型工具,那是唯一会记录跳的路径)。守卫照实复刻部署中第三方插件的做法:**TCP 对端必须是 loopback** + **`Host` 必须解析为自身且是 loopback 主机名** + **浏览器同源标记一致**。第二项挡掉 DNS rebinding 拼法(`localhost.attacker.tld`)与非规范权威(默认端口 `127.0.0.1:80` 解析后会消失,故不相等)。
524
+ 三个端点都是 GET、只读、无写入面(所有变更仍只走模型工具,那是唯一会记录跳的路径)。守卫照实复刻部署中第三方插件的做法:**TCP 对端必须是 loopback** + **`Host` 必须解析为自身且是 loopback 主机名** + **浏览器同源标记一致**。第二项挡掉 DNS rebinding 拼法(`localhost.attacker.tld`)与非规范权威(默认端口 `127.0.0.1:80` 解析后会消失,故不相等)。
250
525
 
251
526
  ⚠️ **信任假设要说清楚**:实测发现**已注册的 exact 路由先于鉴权匹配**——未注册路径返回 401,而注册过的路由直接 200,不要求会话 cookie。所以守卫是这些端点**唯一**的防护,其信任边界是"本机进程",与账本文件本身可被本机读取是同一信任级。反向代理部署需要守卫的共享令牌变体;只服务直接 loopback 是安全的默认,失败模式是"读被拒绝"而非"读被泄露"。
252
527
 
@@ -257,14 +532,33 @@ authored → closed(note) → reopened → contributed
257
532
  | 工具 | 作用 |
258
533
  |---|---|
259
534
  | `peer_list` | 列出你能对话的代理及其与你的关系(上级 / 下属 / 兄弟 / 你开启的同伴 / 账本里有往来的联系人),并标注谁此刻在线 |
260
- | `peer_start` | 开启一个**独立同伴会话**,并把任务作为一份**汇报**交给它 |
535
+ | `peer_start` | 开启一个**独立同伴会话**,把任务作为一份**汇报**交给它,并报出它第一个回合的**观测结果**(在跑 / 已结束 / 失败) |
261
536
 
262
537
  ### 同伴是独立根会话,不是下属
263
538
 
264
- 这是本阶段最重要的设计判断。`peer_start` 创建的会话**没有 `parentSession`、没有 `origin`、`delegationDepth` 为 0**——它是一个根会话。这样它才配得上"同伴":拥有自己的生命周期与预设、不占用任何委派深度预算、出现在工作区会话列表里、**并且比开启它的那一轮活得更久**。
539
+ 这是本阶段最重要的设计判断。`peer_start` 创建的会话**没有 `parentSession`、没有 `origin`、`delegationDepth` 为 0**——它是一个根会话。这样它才配得上"同伴":不占用任何委派深度预算、出现在工作区会话列表里、**并且比开启它的那一轮活得更久**。
540
+
541
+ "独立"说的是**生命周期**,不是**能力**。新生的根会话自己既没有组合(composition)也没有模型路由,缺哪一样它的第一个回合都会当场死掉:没有组合就只看得见空的全局层(没有预设工具、没有预设提示词段);没有模型路由则在提示词组装时解析不出 `{{model}}`——回合在第 1 步就以 `error` 结束,**一个工具都没调到**。所以这两样必须**从开启者身上取**:
542
+
543
+ - **组合**:`agentPresets.composeFrom()` 把同伴绑到开启者**正在跑的那一代**(不是按 id 重新解析——预设文件自开启者启动后被改过或删掉,都不会让同伴跑在另一个世界里);部署没有名册时什么也不绑,也不报错,与委派子代理同一套语义。
544
+ - **路由**:provider / model / reasoning effort 继承开启者的会话请求头(请求头是请求期选择之后的真实路由),未发过请求时回落到它的创建选项,再回落到部署默认模型;**三者都没有就拒绝创建**,而不是造一个注定失败的会话。
545
+ - **预设身份**:绑到的预设 id 写进会话头,否则冷读同伴的日志会按部署默认重建出一个它从没跑过的组合。
265
546
 
266
547
  代价是血缘无法表达这段关系(`parentSession` 是空的),所以**名册日志**(`$DSH_HOME/report-ledger/peers.jsonl`,append-only)记录它:谁开启了谁、何时、什么名字、继承的工作目录。这条记录同时是**授权凭证**。
267
548
 
549
+ ### `delivered` 不等于"有人在干活"
550
+
551
+ 投递只证明摘要到达了一个**常驻**代理;它证明不了回合开没开。而一个在第 1 步就死掉的回合,和一个正埋头干活的同伴,在账本里长得**一模一样**:同一跳 `delivered`、同一行 `live`。所以 `peer_start` 交完任务后会观测一个有界窗口(默认 1500 ms,`DEFAULT_RECEIPT_MS`),如实报出四种之一:
552
+
553
+ | 观测 | 含义 |
554
+ |---|---|
555
+ | `running` | 回合开着,窗口关闭时还开着 |
556
+ | `settled` | 窗口内回合跑了并且结束了(附结束原因) |
557
+ | `failed` | 窗口内回合以 `error` 结束(附错误原文)——这正是过去完全看不见的那一类 |
558
+ | `unstarted` | 收件箱里有活,但从未开过回合 |
559
+
560
+ 这是一次**观测**而不是状态查询:它把"交接完成"与"有人在干"分开,代价是 `peer_start` 多等一个窗口。
561
+
268
562
  ### 授权规则
269
563
 
270
564
  DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、teams、workflows、hosts 保持拒绝,**直到有一个显式的授权协议有生产消费者**"。本插件就是那个消费者,它实现的规则故意收得很窄:
@@ -285,7 +579,7 @@ DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、
285
579
 
286
580
  ### 一处已知限制
287
581
 
288
- 在**一次性 headless 运行**里,开启的同伴不会真的执行任务:它不是子代理,因此不在运行器 drain 的范围内,父任务一结算进程就退出了。任务本身不丢——投递已作为 `agent/inbox/spliced` 进入同伴的会话日志,会话恢复时它就在历史里。在长期运行的 web profile 中同伴会正常处理收件箱。
582
+ 在**一次性 headless 运行**里,开启的同伴不会真的执行任务:它不是子代理,因此不在运行器 drain 的范围内,父任务一结算进程就退出了。任务本身不丢——投递已作为 `agent/inbox/spliced` 进入同伴的会话日志,会话恢复时它就在历史里。在长期运行的 web profile 中同伴会正常处理收件箱(第一个回合的观测窗口在这种情况下最有用:它当场就能告诉你同伴是跑起来了还是死在第 1 步)。
289
583
 
290
584
  ## 工程约定(踩过的坑)
291
585
 
@@ -299,18 +593,44 @@ DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、
299
593
  - **每个副作用都必须在 `apply` 的 `ctx.effect` 里注册。** 在**工具执行体内部**直接调用 `ctx.webServer.register(...)` 并把 disposer 存进闭包,是一个真实的陷阱:该副作用不归插件 fiber 所有,`cordis_stop` 与 `cordis_undefine` 都无法回收它,只能靠重启进程清除(本项目在诊断探针上踩到过一次,正式插件的路由因此写在 `ctx.effect` 内)。
300
594
  - **手改账本不能让记录消失。** YAML 的严格默认会把**重复键**判为错误,而重复键正是手改时最容易出现的情况(追加一个已存在的字段)。那会让整份文档解析失败、汇报从账本里静默消失。所以读取路径用 `uniqueKeys: false`(后者胜),而"无 front matter""未闭合块"这类真正无法解释的输入仍然拒绝。丢失记录远比一个歧义键被可预测地解决严重。
301
595
  - **锚在列表项上的面板,必须保证那一项存在。** 详情面板原本渲染在汇报**行内部**,于是当目标不在(筛选后的)列表里时,面板无处渲染、连同里面的提示一起消失。修法不是把面板抽出来,而是**为被打开的汇报补一行合成行**——这同时修掉了另一个我还没发现的同类缺陷:**在详情打开时改变筛选,面板原本也会消失**。
596
+ - **补出来的那一行要插进它自己的时间位置,不能追加在末尾。** 追加会让 11:12 的汇报显示在 14:30 的汇报下面——偏偏用户正处在「为什么少了一条」的语境里,一个看着像排序坏了的列表比一片空白更误导。落地做法是把排序的比较函数抽出来给插入复用:**排序与插入用同一个比较器**,两份实现一定会漂移。这一条同样钉在测试里(插入后列表仍有序、且新行不是最后一个)。
597
+ - **不要把「整行可点」实现成 `role="button"` 的行。** 行里还有一个任务芯片——一个真的 `<button>`,于是构成嵌套交互内容;更糟的是 `role="button"` 的可访问名**由整棵子树计算**,读屏会把一整行 uuid 念一遍、再把任务标签念第二遍。改法是让行回归普通容器(点击保留为鼠标便捷路径),把展开动作交给左侧箭头:真按钮、名字说清它做什么(`展开 R-0001`)、`aria-expanded` + `aria-controls` 指向它打开的面板(面板带 `id`)。这样每行两个 Tab 停靠点是**两个真实动作**(展开、按任务筛选),而不是同一个动作的两次。⚠️ 箭头**必须 `stopPropagation`**:它和行处理的是同一个动作,不拦住就会切换两次,表现成「点了没反应」——这一类 bug 在自动化里显示为 `aria-expanded` 从 false 变回 false,肉眼则完全看不出区别。
598
+ - **平台组件的枚举要去 CSS 里确认,不能只按已发布视图的用法推断。** `Tag` 的 tone 在平台代码里只出现了 `solid` 与 `neutral` 两种,照此推断就会以为状态只能用灰阶——而 CSS 里其实定义了 8 个。**能渲染的取值集合由 `[data-tone=…]` / `[data-state=…]` 的样式规则决定**,代码里没用到只是没用到。
599
+ - **这些组件不接受 `style`,只接受 `className`,而且落在它们自己的 wrapper 上。** 于是「让搜索框占满工具栏」这件事只能靠一条 CSS 解决,而那条规则的类名必须是**我们自己的**(`report-ledger-search`),不能去写 `_wrap_1g6ru_1` 这类 hash 类名——同一条规则挂在 hash 类名上,就是给自己埋一个随 shell 版本爆的雷。
302
600
  - **接在早退块里的东西可能要不到。** 上面那个"不在当前树里"的提示原本嵌在线程块的 IIFE 内,而该块在汇报没有上溯/下递时会提前 `return null`——偏偏"树外汇报没有线程链接"正是常见情形。条件渲染里的早退会静默吞掉同一块里其它独立的内容。
601
+ - **`whiteSpace: 'nowrap'` 的样式对象不能复用到「长度由数据决定」的单元格上。** `time` 这个样式对象本来只描述时间戳,却被顺手复用到了传递路径的收件人/备注列上;而 `1fr` 网格轨道的自动最小尺寸就是 min-content——对一整行不可断行的文本来说,那就是整行宽度。结果网格宽过自己的面板、整个标签页多出 426px 横向滚动,备注被切在屏幕外,**而那个滚动条只在标签页的最底部才够得着**(容器被外层撑到 1314px,横向滚动条在它自己底部)。同一类问题在 flex 项目上表现为「不写 `min-width: 0` 就永远缩不下去」,于是汇报行的 meta 行、账本路径、待送达列表是同一个毛病的三处实例。修法是分成两个样式:时间戳保持 nowrap,内容一律 `whiteSpace: 'normal'` + `minWidth: 0`;需要保持单行密度的地方用 `overflow: hidden` + `textOverflow: 'ellipsis'`,而不是让它去撑破容器。
602
+ - **在浏览器里量,而不是在浏览器里看。** 上面那条缺陷在截图里只是"文字好像被切了",`getBoundingClientRect` 与 `scrollWidth/clientWidth` 一量就是精确的一句话:网格 1052px、单元格右边缘 1478px、shell 溢出 426px。凡是"布局被内容撑破"这一类,肉眼只能给出怀疑,测量才给出结论。
303
603
 
304
604
  ## 开发与验证
305
605
 
306
606
  ```sh
307
607
  pnpm build # 产出 lib/index.js(host 半)与 lib/client.js(client 半)
308
- pnpm test # 五套确定性检查共 255 项断言:账本内核、生命周期与任务分组 56 + 提示词角色分流 42
309
- # + 时间线装配与路由守卫 59 + 同伴名册与创建 42 + 时间线模型(筛选/线程/正文)56
310
- #(不需要 DSH,不触碰真实账本,不启动服务器)
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
618
+ #(不需要 DSH,不触碰真实账本,不启动服务器;worktree 相关的四套会在临时目录里建真仓库)
619
+ pnpm test:tools # 工作区工具面 50 项 —— 只能在有官方包层的地方跑,见下
311
620
  pnpm typecheck # 对部署中的 harness 类型做全量类型检查
621
+ pnpm check # 以上三者的合并,也就是完整的本地门禁
312
622
  ```
313
623
 
624
+ **为什么工具面那一套不在 `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` 之后必然解析不到。
628
+
629
+ 这与 typecheck 面对的是同一个约束(CI 里没有官方包层),处理方式也刻意一致:**CI 跑它跑得了的
630
+ 那部分,跑不了的留在本地门禁里**。`pnpm check` 是完整门禁,`pnpm test` 是它可移植的子集——`ci.yml`
631
+ 与 `publish.yml` 都只跑后者。把这个分工写在脚本名上而不是注释里,是为了失败信息不至于指向一个与
632
+ 被测代码毫无关系的模块名。
633
+
314
634
  **安装到 profile**(本仓库已这么装好):包经 junction 出现在 `~/.dsh/profiles/node_modules/dsh-report-ledger`,并在 profile 的 `cordis.patch.yml` 中有一行:
315
635
 
316
636
  ```yaml
@@ -319,6 +639,9 @@ pnpm typecheck # 对部署中的 harness 类型做全量类型检查
319
639
  name: 'dsh-report-ledger'
320
640
  config:
321
641
  announceToAgent: true
642
+ # 可选:打开工作区支持(见「工作区(可选)」一节)
643
+ # worktree:
644
+ # enabled: true
322
645
  ```
323
646
 
324
647
  **开发回路:**
@@ -327,7 +650,10 @@ pnpm typecheck # 对部署中的 harness 类型做全量类型检查
327
650
  - 已实测确认:改 `lib/index.js` 后新代码即刻生效,**且宿主进程 PID 不变**。本插件被判定为"直接变更"走局部重载,不会触发 `loader.exit()`(那是 **CLI 入口静态依赖树**里文件改动才会走的路径;插件由 Loader 动态 `import()` 加载,不属于那棵树)。
328
651
  - 重载是安全的:插件的持久状态全在磁盘账本上,内存里只有一个互斥锁表,重载不丢数据。
329
652
  - ⚠️ **启用 `hmr` 需要一次重启才生效。** 通过 `patchReload: live` 在运行中启用只会"启用行"而**不应用 `config`**——实测服务自己报 `root: []`(空监视)与 schema 默认 `debounce: 100`。组合树本身是对的(`dsh --profile web --dump-config` 可见完整 config),只是生效时机问题。
330
- - **客户端半:重建 + 页面刷新。** HMR 不覆盖 `lib/client.js`(由 `dsh-client-modules` 提供)。"无需刷新自动重载"未验证——不确定 `dsh-client-hmr` 的监视根是否覆盖 profile 之外的包。
653
+ - **客户端半:保存即生效,同样不需要刷新页面(已实测)。** 原先这里写的是「重建 + 页面刷新」,并注明"未验证"——**那条是错的**。`dsh-web-app` 的 `client-hmr` 行是**常驻**的(`dsh-web-app/cordis.patch.yml`:*always mounted: it is idle until a rebuild watcher actually rewrites client bundles*),其 node 半侧每 `pollIntervalMs`(默认 500ms)stat 轮询**每个图 bundle**,变化时经 `/plugins/events` 的 SSE 通道广播 `rebuilt` 帧;浏览器半侧据此 `invalidate` → `prefetch` 新 factory → 拆旧 fiber → `entry.refresh()` 重新挂载。插件经 junction 挂在 profile 之外**不影响**这条链路:轮询的是解析后的真实路径。
654
+ - 实测方式与结果:直接改写插件 `lib/client.js` 里的 `"view.tab"` 字面量(不重建源码、不刷新、不点击),标签在**约 1 秒内**变成新值;改回去又自动回退。两次 `performance.getEntriesByType('navigation').length` 始终为 1,页面没有重新导航。也就是说 `pnpm watch`(或任何写 `lib/client.js` 的构建)对客户端半就是**完整回路:保存 → 重建 → 页面自己换掉这个插件**。
655
+ - 代价与边界:换掉的是**插件**,插件内的 React 状态会丢(展开的详情会收起),而会话、工作区与连接状态不受影响;重载失败不回滚,该 entry 停在 FAILED 视图并在下一次 `rebuilt` 帧从头重试。
656
+ - **仍然需要刷新页面的只有一种情况:启动图本身变了。** 装/卸插件、启用/禁用某一行(即 `dsh.client` 名单变化)只在页面加载时组合——每个 `rebuilt` 帧只携带单个插件产物的 revision,不替换启动图。
331
657
  - **`pnpm watch` 的生命周期**:它是个前台常驻进程。由代理会话启动的那种只在该会话存活期间有效;要长期常驻请在自己的终端里跑。
332
658
  - 离线/批量集成验证仍可走独立的 headless profile(`~/.dsh/profiles/reports-dev/`),它一次性跑任务、不干扰正在服务的 GUI:
333
659
  ```sh
@@ -354,7 +680,7 @@ pnpm typecheck # 对部署中的 harness 类型做全量类型检查
354
680
  分发形态是**预构建的 bundle**:`lib/` 在发布前构建好,用户安装时不跑任何构建脚本,因此不需要 `allowBuilds` 授权。
355
681
 
356
682
  ```sh
357
- pnpm check # 类型检查 + 五套确定性检查
683
+ pnpm check # 完整门禁:类型检查 + 十二套可移植检查 + 工具面那一套
358
684
  pnpm pack # 先出 tarball 核对产物(prepare 会顺带构建)
359
685
  npm publish # ⚠ 本机 registry 若是镜像站,必须显式 --registry=https://registry.npmjs.org
360
686
  ```
@@ -377,6 +703,29 @@ dsh --profile demo --dump-config # 应出现 `# == dsh-report-l
377
703
 
378
704
  `dsh plugin add` 会因包声明了 `dsh.bundle` 而**自动**把包名追加进 `dsh.profile.bundles`,用户不需要手改配置。
379
705
 
706
+ **CI 发布(可信发布 OIDC):** `.github/workflows/publish.yml` 负责推 tag 后自动发布——不需要任何 npm 令牌,
707
+ 也不需要手输一次性验证码,并自动附带 provenance。**首次启用前要在 npm 侧建立一次信任关系**(需交互式 2FA):
708
+
709
+ ```sh
710
+ npm trust github dsh-report-ledger --file publish.yml \
711
+ --repo stone-brick/dsh-report-ledger --allow-publish
712
+ ```
713
+
714
+ `--file` 必须与工作流文件名完全一致。之后发版就是 `pnpm version patch && git push origin main --follow-tags`。
715
+ 注意 CI 只跑 `pnpm test` + 构建,**不跑 typecheck、也不跑 `pnpm test:tools`**:两者的原因相同——都需要
716
+ profile 的官方包层(见下文 junction 一节),CI 里没有这一层;把官方包装成 devDependency 反而会在工作区
717
+ 复制服务注册表、破坏实例同一性。
718
+
719
+ **`ci.yml` 只跑测试,不发布。** 在它之前,本仓库**唯一**跑测试的地方就是 `publish.yml`——而它一推 tag 就
720
+ 发布。也就是说这套断言从未在 Linux 上跑过,却要拿它决定一个版本能不能发出去。`ci.yml` 把这两件事分开:
721
+ 推 `main` 与每个 PR 都跑一遍可移植的检查加一次构建,于是"测试在 Linux 上过不过"有了独立答案,而推 tag
722
+ 之前的失败代价从"一个已挂出去的版本"降到"一条红的运行记录"。
723
+
724
+ 首次发布(新包名)只能手工来一次:npm 的**可信发布与暂存发布都要求包已存在**,新包名两者都会 404。
725
+ 手动发一次时如果是安全密钥账号,`npm publish` 会打印一个 `https://www.npmjs.com/auth/cli/…` 链接,
726
+ 在浏览器里完成认证即可(放行凭据用 `--//registry.npmjs.org/:_authToken=…` 传,别写进 `.npmrc`)。
727
+
728
+
380
729
  **git 安装与 npm 安装不是一回事**:`add github:<你>/dsh-report-ledger#<sha>`(或 Gitee 地址)拉到的是**源码**,
381
730
  靠仓库里的 `prepare` 构建出 `lib/` 才能跑——本仓库实测在 pnpm 10.14 上直接通过、未要求 `allowBuilds`,
382
731
  但部分 pnpm 版本会拦截依赖的构建脚本,届时 `dsh` 会打印出要写进 profile 的 `pnpm-workspace.yaml` 的包键。
@@ -397,11 +746,20 @@ Gitee 自带的「仓库镜像管理」在本账号不可用(`GET /api/v5/repo
397
746
 
398
747
  ```sh
399
748
  curl -X POST "https://gitee.com/api/v5/repos/stone_zhan/dsh-report-ledger/releases/<release_id>/attach_files?access_token=<token>" \
400
- -F "file=@dsh-report-ledger-0.1.0.tgz"
749
+ -F "file=@dsh-report-ledger-0.2.0.tgz"
401
750
  ```
402
751
 
403
752
  ## 已知限制
404
753
 
405
- - **单进程假设。** 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。
754
+ - **单进程假设。** 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。工作区的**占用判定**另有一把按仓库为键的锁(跨报告抢同一支分支是单报告锁看不见的),但它同样只在本进程内有效。
406
755
  - **正文并发覆盖。** 跳流 append-only 永不丢跳,但两个人同时改写同一份正文是后写者胜(`report_contribute` 用追加,规避了常见路径)。
407
756
  - `fromName` 取自会话标题,是"会话名"而非"代理名"。
757
+
758
+ 工作区相关的限制(该功能默认关闭,未开启时都不适用):
759
+
760
+ - **隔离是有限的。** 独立的是工作目录与索引;`config`、`hooks`、对象库与检出之外的一切都共享。装一个 hook 会影响所有检出的每一次提交。
761
+ - **没有"接管"策略。** 缺席的持有者不会被抢走检出;只能等它自己 `report_release` 或结案。
762
+ - **维护视图只报告,不修复。** `GET /api/report-ledger/worktrees` 会列全每个检出的磁盘实况与每一处异常,但清理仍然是人(或 `report_close` 的 `remove-if-clean`)的动作。自动 `prune` 会毁掉一个被手工搬走、却从自己那侧照常可用的检出,所以它不在这里做。
763
+ - **回收是自发的、不是排定的。** 只有在下一次分配超出 `maxRetained` 时才会清一个;平时不会主动整理。
764
+ - **绑定不能清空,只能覆盖。** `report_amend` 不管绑定;要改正一个指向已消失检出的绑定,办法是 `report_lease` 认领另一个有效检出。
765
+ - **含子模块的仓库直接拒绝分配**,没有逃生开关。