Skip to content
dsh.fish
Bundle

dsh-report-ledger

Report-and-ledger plugin for DSH: front-matter 汇报 as the first-class agent-to-agent interaction primitive, with upward/downward delivery, co-authorship, CC, and a complete transfer-path audit trail; plus a conversation timeline tab.

Source
stone-brick
License
MIT
Updated
Updated yesterday

Readme

# dsh-report-ledger

一个 DSH 插件:把「**汇报**」做成代理之间的一等交互原语,并为长期协作留下可追溯的账本。

## 安装

```sh
dsh plugin --profile web add dsh-report-ledger
```

这条命令把包装进该 profile 的 `node_modules`;因为包声明了 `dsh.bundle.patch`,
`dsh plugin` 会**自动**把 `dsh-report-ledger` 追加进 profile 的 `dsh.profile.bundles`,
不需要手改任何配置文件。装完**重启 profile**(`dsh web`)即可生效。

不启动也能验证装上了:

```sh
dsh --profile web --dump-config     # 配置树里应出现 report-ledger 这一行
```

卸载走同一条通道,依赖与该配置层会一起移除:

```sh
dsh plugin --profile web remove dsh-report-ledger
```

### 国内镜像(Gitee)

源码与发行版同步在 [gitee.com/stone_zhan/dsh-report-ledger](https://gitee.com/stone_zhan/dsh-report-ledger),
**不经过 GitHub** 也能装:

```sh
# 1) 下载发行版附件(预构建产物,不需要构建工具链)
#    https://gitee.com/stone_zhan/dsh-report-ledger/releases/download/v0.1.0/dsh-report-ledger-0.1.0.tgz
dsh plugin --profile web add ./dsh-report-ledger-0.1.0.tgz

# 2) 或直接从 Gitee 安装(安装时自动构建,约 15 秒)
dsh plugin --profile web add git+https://gitee.com/stone_zhan/dsh-report-ledger.git
```

GitHub 每次推送后由 `.github/workflows/mirror-to-gitee.yml` 自动镜像 `main` 与 tags。
注意:**npm 安装走的是 npm 镜像站而不是 GitHub**,所以 `npm publish` 之后上面这条一行命令
才是国内用户最省事的路;Gitee 镜像解决的是「拿不到 GitHub」时的源码与产物可达性。

### 装完你会得到什么

- **宿主半**(任何 profile):十个 `report_*` 工具 + `peer_list` / `peer_start`、
  两段 `systemPrompt` 段,以及 web profile 下的两个只读 HTTP 端点;
- **浏览器半**(仅 web profile):会话头部多一个「汇报」标签页 —— 时间线、状态芯片、
  搜索、线程跳转与就地展开的传递路径。

### 兼容性、权限与数据

- **DSH 0.1.5-rc.3 实测可用**(构建产物与该版本的平台模块表对齐)。宿主半的运行时
  外部依赖只有一个:`@deepseek-ai/dsh-tools`,由 profile 提供;浏览器半 require
  `react`、`react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives`(面板里的
  按钮、芯片、悬停、状态点、输入框与正文渲染全部用它,与内置的 Chat / 轨迹视图同一套),
  其余全部内联。宿主半在 headless profile 里同样可用(没有 web server 时只是少了那两个端点)。
  ⚠️ 于是浏览器半多了一条硬依赖:**shell 的冻结模块表里必须有
  `dsh-client-ui-primitives`**。它自带的对话视图就依赖它,所以缺了它意味着那个 shell
  本身已经坏了;真缺的话失败模式是「汇报标签页不出现」而不是整个 GUI 崩。
- **账本是本机文件**:`$DSH_HOME/report-ledger/`(可用 `DSH_REPORT_LEDGER_ROOT`
  覆盖),不上传任何地方。
- **浏览器读数据走两个 GET 路由**,注册在 DSH 的 web server 上,守卫仅放行 loopback
  对端与 loopback `Host`;细节与信任假设见下文「守卫与信任假设」。
- **同伴工具会新建根会话**(在你自己的工作目录下),这是本插件唯一会新增活代理的能力,
  每次创建都记进名册日志,并受 `maxPeersPerAgent`(默认 8)约束。

### English quick start

```sh
dsh plugin --profile web add dsh-report-ledger   # install + register the bundle layer
dsh --profile web --dump-config                  # verify: a `report-ledger` row appears
dsh web                                          # restart the profile to load it
```

Tested against DSH 0.1.5-rc.3. The ledger is plain files under
`$DSH_HOME/report-ledger/` and nothing leaves the machine. The host half works in
any profile; the web profile additionally gets a **Reports** tab in the GUI.

> 下面是**用户安装**。本仓库自身的开发装法(junction + `hmr` 热重载)见末尾
> 「开发与验证」。

## 为什么需要它

DSH 原本的代理间通信是**相邻 Agent 的 steer 投递**(`ctx.subagents.sendMessage`),它明确承认这些缺口:

- 只能投给**直接子代理**或**直接父代理**,兄弟、祖辈、无关同伴一律 `UNAUTHORIZED`;
- **没有持久 mailbox**:父代理不在线时消息被拒绝,而不是"先接受、后送达";
- **没有抄送、没有优先级、没有回执、没有线程、更没有传递路径记录**(`AgentMessageSource` 只带一个 `senderSessionId`)。

本插件补上的是最后那一块地基。它不新造会话事件类型(那会让会话日志在重载时不可读,见下),而是复用 harness 已有的白名单词汇与公开 API。

## 核心设计

**汇报是两层结构。**

- **front matter 是缩略**:主题、收发、抄送、共写者、跳数、最后跳。它是唯一进入模型上下文的部分,因此长期协作的上下文开销是有界的。
- **正文留在账本里**,由 `report_read` 按需打开(超长时只返回头部与文件定位,模型用 `read` 分页取余下部分)。

**传递路径是权威的。** 每次操作都追加一跳(append-only JSONL),因此:

- `to` / `cc` / `authors` / `hops` / `last` 都是**从跳流派生**的缓存,任何下一条跳都会重新校正它——账本不可能显示历史里没有的收件人;
- **待投递集合也由跳流派生**:被投递过(`sent`/`cc`/`forwarded`/`copied`)但没有到达(`delivered`)的收件人即为待投递。**审计轨迹本身就是那个缺失的持久 mailbox**,所以它天然跨重启存活,且不可能与历史不一致。

**投递走公开的 Agent 入口。** 模型工具受"精确相邻"约束,但 `Agent.steer/inject` 只接收一条消息、不做授权检查,而任何活 Agent 都可由 `ctx.agents.get(id)` 取到。因此:

- 主送(`to`)用 `steer`——空闲目标会因此开启一个回合;
- 抄送(`cc`)用 `inject`——进入上下文但不唤醒任何人;
- **非驻留收件人不会被拒绝**,而是留在待投递集合里,等 `agent/created` 事件到来时自动送达。

## 账本布局

根目录 `$DSH_HOME/report-ledger/`(可用 `DSH_REPORT_LEDGER_ROOT` 显式覆盖,便于部署迁移与隔离测试):

```
reports/R-0001.md            front matter 缩略 + 正文
reports/R-0001.route.jsonl   append-only 传递路径,一行一跳
```

选择文件而非私有数据库,是因为账本是一段协作的长期记忆:它必须可被人直接检查、手工修正(写错主题、补一句 hop 说明都不需要迁移),而 front matter 让"只读缩略"不必打开正文。

## 工具

| 工具 | 作用 |
|---|---|
| `report_author` | 开一份汇报,可同时主送 `to` 与抄送 `cc`,可用 `parent` 关联上溯汇报 |
| `report_contribute` | 以共同作者身份追加自己的一节(互不覆盖,各自记为独立跳) |
| `report_send` | 主送给更多代理(唤醒):向上回报、向下派发 |
| `report_cc` | 抄送给更多代理(不唤醒):让需要知情者持有副本 |
| `report_forward` | 转呈下去,或 `mode:"copy"` 作为参考副本分发 |
| `report_read` | 读缩略 + 完整传递路径 + 正文(读取本身也记一跳) |
| `report_list` | 只列缩略,可按 `session`、`status`、`task` 过滤,用于纵览长期协作 |
| `report_ack` | 回执,让发送方看到闭环 |
| `report_amend` | 修正:改缩略字段或更正正文,仅发起者/共写者可改,改动会被记录 |
| `report_close` | 结案:事情的终结,仅发起者/共写者可关 |

## 修正:记录,而不是抹掉

一个 agent 写错了主题时,原本只有两个坏选择:重开一份新汇报(丢掉原有传递路径与收件人),或者让错误留着。而人手改文件又能改——**这个不对称是实现漏洞,不是设计取舍**。

难点在于:账本的价值来自**历史只增不改**;如果 agent 能静默重写记录,账本就不再可信。所以 `report_amend` 的规则是**修正必须被记录**:

| 对象 | 语义 | 理由 |
|---|---|---|
| 缩略字段(`subject` / `task` / `artifacts`) | **替换**,跳里逐字记录改前改后的值 | 它们是**当前状态**,不是历史;而"从什么改成了什么"正是审计要的 |
| 正文 | 默认**追加**一段 `### amendment` | 正文记录的是**人说过的话**,改写别人的话就不是记录了 |
| 正文(`replace_body: true`) | 真替换,且跳里注明"body replaced" | 有时确实需要重写;那就**让后人知道被重写过、被谁重写**,而不是被误导 |

**收件人与共写者永远不能在这里改**——它们由跳流派生,唯一的修改途径就是再追加一跳。`task` 传空串可以**清除**标签(避免出现"空标签"与"无标签"两种含义)。

修正属于活动,所以**给已结案的汇报做修正会自动重开它**并记录 `reopened`。

实测效果:一个写了错别字的主题被改正后,落盘是

```
front matter:  subject: "the corrected title"          ← 当前真相
传递路径:      amended  # subject "teh wrong speling…" -> "the corrected title"   ← 历史
```

## 汇报的生命周期

三个状态:`open` → `acked` → `closed`。

| 动作 | 谁能做 | 效果 |
|---|---|---|
| `report_ack` | 任何收件人 | 记一跳;`open` → `acked`。**回执 = "我收到了"** |
| `report_close` | **仅发起者或共写者** | 记一跳;→ `closed`。**结案 = "这件事结束了"** |
| 贡献 / 主送 / 抄送 / 转呈 | 任何有权限者 | **自动重开**:先记 `reopened` 跳,`closed` → `open`,随后才落下活动本身那一跳 |

两个刻意的设计决定:

**1. 结案是拥有者的事,不是读者的事。** 只有发起者或共写者能关闭;被拒绝时错误信息会**报出该找谁关**(列出作者),而不是只说一句"不行"——这样代理知道下一步该做什么。`from` 与 `authors` 都算拥有者,因为手改过的账本可能缺对应跳,而拒绝明显的主人会让汇报永远悬着。

**2. `closed` 是状态,不是锁。** 如果有人在关闭后又贡献/主送/抄送/转呈,那这件事显然又活了,于是自动重开并记录 `reopened` 跳。否则一份被过早关闭的汇报会**静默吞掉后续工作**——对一份以可追溯为全部意义的账本来说,这是最糟的失败模式。

所以**不需要单独的"重开"工具:活动本身就是重开**。回执与结案也是刻意不合并的两件事:回执表示"我收到了",事情仍在推进;结案表示"这件事结束了"。对已关闭的汇报回执只记跳,**既不重开也不降级为 acked**。

状态机每一次转换都落在 append-only 路径里:

```
authored → closed(note) → reopened → contributed
```

未来读者看到的是过程,而不只是当前终态。

## 提示词:协议与伙伴契约

插件注入**两段** `systemPrompt.section`,这个拆分是有意的:

| 段 | 名字 / 顺序 | 求值方式 | 理由 |
|---|---|---|---|
| 协议 | `plugin:report-ledger` / 200 | 静态字符串 | 对所有 agent **逐字节相同**,因此是各 scope 里稳定的提示词前缀(KV cache 友好) |
| 伙伴契约 | `plugin:report-ledger:partnership` / 201 | 按 assembly 求值的函数 | 契约要对两种读者说不同的话 |

**协议段**说明账本是什么、八个工具各做什么,以及两条会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成。

**伙伴契约段**分两种读者:

- **对所有 agent**:明确否定"工具"框架——*其他代理不是「做完一项任务就可以终止的工具」,而是长期相处的同伴*;一次任务的结束是这段关系的逗号而不是句号;交接用汇报而非口信;收到汇报要回执;兄弟代理之间也可以互相抄送。
- **仅对被委派的子代理**:你的会话是持久的、不是一次性函数调用;**主动汇报,不要默默结束**;**你的父会话 id 是 X,可直接主送到它**;权限范围启动即固定,需要越界时把限制写进汇报而不是反复重试。

**角色判定是同步且精确的**,来自 `Session.header`(`origin === 'subagent'` 或 `delegationDepth > 0`)——持久、首次组装时就在,无需查询、无需缓存、无异步竞态。子代理的父会话 id 也由此取得并**直接内联进提示词**,因为不给出这个地址,"向上汇报"就只是一句无法执行的口号。

## 时间线标签页

`conversation.view` 槽位里注册一个**新 id** 的视图(不覆盖已发布的 Chat / Trajectory),浏览器把 list 型槽位投影成会话头部的标签页。

页面渲染**一条自上而下的时间线**,把两类事件按时间合并:

- **会话分叉**:子树里每个会话一行,按深度缩进,标注「子代理 / 在线」;
- **汇报出现**:每份汇报一张缩略卡(状态、主题、收发、抄送、跳数、最后动作),点击就地展开**完整传递路径 + 正文 + 仍在等待送达的收件人**。鼠标点整行即可;键盘走左侧箭头——那是一个真按钮,带 `aria-expanded` 与 `aria-controls`,名字是「展开 R-0001 / 收起 R-0001」。

### 筛选与搜索

汇报多起来之后时间线需要收窄,所以工具栏提供:

- **状态芯片**:`全部 / 进行中 / 已回执 / 已结案`,**标签里带数量**。数量取自完整载荷而非筛选后的行——一个自己会变的标签会让你看不出到底排除了多少。
- **搜索框**:匹配汇报编号、主题、发起者 id 与名称、主送、抄送、共写者、任务标签、产物路径,以及**会话标题与编号**。
- **清除**按钮(仅在筛选生效时出现),状态选择记在 `localStorage`,搜索文字刻意不持久化。

两条刻意的规则:

**状态筛选只作用于汇报;搜索作用于每一行。** 会话没有生命周期状态,所以状态芯片不会隐藏会话——时间线的骨架始终在,缩进也就始终有意义(汇报的 depth 来自它的发起会话,与会话行是否被渲染无关)。

**空状态分两种。** 「这个会话树下还没有汇报」与「没有符合当前筛选的汇报」是不同的话,混用会让人以为账本坏了。判定下沉在 `timeline-model.ts` 的 `emptyState()` 里,由测试钉住:前者按「载荷里有没有汇报」判,后者按「筛选后还剩几行**汇报**」判——只数汇报行,因为会话行按设计不受状态芯片影响,把它们算进去会让一棵满是会话的树自称「你把结果筛没了」。账本为空时永远说第一种:此时筛选不可能是原因,归咎于它只会让人去找一个自己从没设过的筛选。

⚠️ 这里踩过一次:初版把「树下还没有汇报」挂在「一行都没有」上,而只要有会话就至少有那一行会话——于是这句话永远不出现,空账本渲染成一片没有任何解释的空白。**空状态的判据必须落在载荷上,不能落在渲染出来的行上。**

行构建与筛选逻辑都在 `src/client/timeline-model.ts` —— 一个**不依赖 React 与 DOM 的纯模块**。这样"哪些行出现、搜索匹配什么、芯片怎么计数"这些用户直接感受到的决定能用确定性测试钉住,而不必靠看浏览器;视图只负责渲染。

### 缩略卡上的身份与用词

**id 缩短,全值在悬停里。** 会话 id 是 36 字符,而一份汇报的收件人是一串 id,于是未缩写的缩略卡几乎全是 uuid,人能用的信息接近于零。所以**密集行**显示前 8 位(`01edba72`),悬停给出「会话标题 · 完整 id」;**详情面板保留完整 id**,因为那是有人要复制 id 的地方,而缩略卡不是。会话行也带上自己的短 id,于是卡片上的 `from=`/`to=` 能读回它属于哪棵树。实测副作用是好的:改短之后 7 张卡的 meta 行**没有一行再触发省略号**——`overflow: hidden` + `text-overflow: ellipsis` 从日常现象退回成安全网;而这层安全网仍然必要,因为收件人数量没有上限,宁可截断也不能让一行把整个标签页顶宽。

**不拿会话标题当行标签**,尽管它更「像人话」:`fromName` 取自会话标题,而标题是会话的第一句提示词——它**没有长度上限**,有时比它替换掉的 id 还长(本仓库里的子代理标题正是 `You are doing READ-ONLY research`,四个子代理一模一样)。把定长但不可读的 id 换成不定长且会重复的标题,只是把一种不可读换成另一种,所以名字放在悬停里、放在会话行上,不放在密集行里。

**词表只有一份。** 卡片上的状态芯片复用工具栏筛选芯片**同一批键**(`进行中 / 已回执 / 已结案`):它们本来就在命名同一个状态,给它们两套词(卡上写 `open`、工具栏写 `进行中`)等于把建映射的活推给读者,而那个映射本身就是缺陷。跳动作同理,12 个动作各有词(`发起 / 共写 / 主送 / 送达 / 抄送 / 转呈 / 副本 / 阅读 / 回执 / 修正 / 结案 / 重开`),由 `HOP_LABEL` 对动作全集取 `Record` 保证「新增一个动作不可能没有词」。**账本文件与模型看到的仍是原文 token**——只有人的视图说人话。

### 用平台的组件库,而不是手搓

面板里**每一个可见控件**都来自 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、暗色适配因此全部不再由本插件负责。

**合法取值是读 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`**。只按代码里的用法去猜,会以为只有三种语气色。

**组件不吃 style,吃 `className`,而且落在它自己的 wrapper 上。** 于是本插件唯一自己写的 CSS 是一行布局:把工具栏的搜索框撑满(`client/styles.ts`)。规则挂**我们自己的类名**,绝不碰 hash 类名——那是会随 shell 版本变的东西。

**顺手换来的一个能力**:账本路径旁边多了复制按钮,走 shell 的 `writeClipboard`(而不是 `navigator.clipboard`),这样它继承的是整个 GUI 都在用的那条降级链。

⚠️ **汇报行故意没有换成 `DisclosureRow`**,尽管它正好是「一行摘要 + 展开正文」的形状,而且已发布的视图用了 8 次。原因写在 `ReportsView` 里:它的两种模式各和已经提交的东西冲突一处。`expandOnRowClick: true` 时整行是 `role="button"`——行内的任务芯片立刻变成嵌套交互内容,而且可访问名由整棵子树计算(读屏会把整行 id 列表念一遍)。`expandOnRowClick: false` 时前导按钮换成一个库内部的按钮,它的可访问名由那个图标决定,而**展开后图标会被替换掉**,名字随之消失;同时整行点击也没有了。所以行**外壳**保留自己的实现(普通容器承担鼠标便捷点击、具名展开按钮承担键盘路径、面板是兄弟节点所以点正文不会收起),**里面的控件全部换成库组件**。要用 `DisclosureRow` 的话,代价是把「点任务标签即分组」降级成展开后再点——这是个产品取舍,不是技术限制。

### 拓扑总览:竖向泳道

列表上方是一张**竖向泳道图**:一条泳道 = 一个会话(列),时间向下(行),每一份汇报画成发起者泳道上的一个节点,它的传递路径画成跨泳道的边。

(**这一版已不在标签页里渲染**:卡片画布接手了图表位,见下面「下一版拓扑」;`TopologyView.tsx` 与 `topology-model.ts` 保留在仓库里、不再被引入,因此不进 bundle,它的几何仍由 `scripts/topology-check.ts` 钉着。下面这段记的是它验证过的东西——结论全部沿用,换掉的只有"节点不承载内容"这一件事。)

```
泳道(会话, 树序) →   afcbce3a │ 01edba72 │ 05286f38 │ …  │ 树外
时间 ↓                        │          │          │    │
  19:00  ●R-0001 ─────▶──▶──▶┄┄▶  (主送实线、抄送点线)
  20:30            └╌╌●R-0002 ──▶  (线程竖向、回执向回)
  19:45                        ●R-0003 ┄┄┄┄┄┄┄┄┄┄┄┄▶
```

**为什么是竖向、为什么不需要图引擎。** 这个标签页本来就纵向滚动,时间向下与下面列表的阅读方向一致;而布局**不需要任何图算法**——泳道序就是子树 DFS 序(列表缩进用的同一个序),行序就是时间序(列表排序用的同一个序),两个 rank 都是现成的。通用引擎要解的是**交叉最小化与端口路由**,是这份数据没有的问题,却要为此付出:`elkjs` 是 EPL-2.0/GPL 且解包 8 MB,`d3-dag` 虽是 MIT 但为最优交叉最小化拖进一个线性规划求解器,`@xyflow/react` 1.2 MB 且自带一套样式表。所以这一版**零新增依赖**,几何由 `src/client/topology-model.ts` 这个纯模块给出。

**它是什么、不是什么。** 它是**总览**,不是第二个阅读面:节点点击后**滚到列表里那张卡片并展开**——正文、传递路径、待送达仍在同一个地方读。这样图不必处理 4000 字的正文,也不承担"键盘/读屏唯一入口"的责任:列表一直在,图只是加在它上面。

**树外会话有一条共享泳道。** 被 cc 进来的、或发起者在别的树里的汇报,如果没有这条泳道就无处落点;它们全部落在最后一列 `树外`。

**这条横向滚动是刻意的。** 视图自己有一个受控的滚动框(宽出即可横向滚动、高限 46vh),与之前那个"内容撑破整个标签页、滚动条只在最底部够得着"的意外滚动条不同:这里是**包在一个框里**的。默认几何(泳道 120px、沟槽 84px)是照"八条泳道刚好放进一个标签页"定的(实测 1108px,无需横向滚动)。

**布局被 34 条断言钉住**:泳道序等于子树序、节点落在发起者泳道、边落在收件人泳道且同一行、重复收件人只画一条、发给自己的不画、树外落点归到共享泳道、线程边只在线程两端都被画出时才存在、空输入不产生 NaN、同一输入给出逐字节相同的几何(见 `scripts/topology-check.ts`)。

**图例必需,逐边文字不必需。** 四条线型如果没人解释,读者只能猜——所以标题旁有图例,而且它的样例**用的就是画线的同一批 class**(`edgeClass()`),改了线的样子不可能留下一个说谎的图例。但**每条边都挂文字是重复的**:线型已经编码了主送/抄送/共写。所以文字只在**悬停某一份汇报时**出现,回答的是"我正在追的这条路径是什么关系"。这也正是 Mermaid 泳道文档里 "Label Cross-Lane Handoffs" 想说的那件事,只是它的载体是线型 + 悬停,不是常驻标签。

**线程走泳道之间的缝隙。** 父子连线跨行,直接画曲线会穿过中间的行与节点;所以模型为它算出 `viaX`——**一条泳道边界**,节点都居中在泳道里,边界离任何节点都有半个泳道远,连线因此完全不碰节点。这条规则也进了断言(边界必须严格落在两列中心之间、且从不落在任何一列的中心上)。

### 规模:布局是免费的,渲染才是成本

在一份 300 份汇报的账本上量过(隔离实例,真实浏览器):

| 量的是什么 | 结果 |
|---|---|
| **纯布局**(500 泳道 × 5000 汇报 × 7916 条边) | **3.4 ms**(20 次中位数)——布局永远不是瓶颈 |
| 渲染元素(300 汇报,改之前) | 5469 个:838 条边各自一个 `<path>` + 838 个箭头 `<polygon>` + 300 条分隔线… |
| 悬停一次(改之前) | **89 ms** —— 每次 mouseenter 要改 831 个 `<g>` 的透明度 |

两处修法都有实测回报:

1. **同类边合并成一条 `<path>`,箭头改用 SVG `marker`**——边本来就不可点击,一条边一个元素只买到"每次重绘一个形状"。箭头是路径的装饰而不是节点,交给 `marker` 后额外元素为零。
2. **淡出只作用于"边的那一层",绝不走后代选择器。** 这是最有价值的一条:单独翻写一个被**后代选择器**匹配的属性(`[data-hot] … :not(…)`)本身就要 **35 ms**,因为浏览器要为整棵子树重算样式,**哪怕最终没有任何可见变化**(我用注入 CSS 把结果改成 `!important` 覆盖也降不下来——覆盖只改结果,不减重算)。所以节点干脆不淡出:追踪一条路径时该退到后面的是**关系**,不是参与者。

结果:元素 **5469 → 2689**、边路径 **838 → 10**、箭头多边形 **834 → 0**、悬停 **89 → 44 ms**(最好的一次 16–19 ms,即一帧)。

3. **窗口化**:只渲染滚动框里真正看得见的那几行(上下各留 4 行 overscan)。300 份汇报时任意时刻只渲染 14–20 行,元素 **2689 → 265**、悬停 **44 → 32 ms**——而 32 ms 就是**测法的地板**(每次采样等两帧,60 Hz 下约 33 ms),也就是已经没有可测的开销了。四个滚动位置都验过窗口覆盖视口(顶部/中部/底部/偏后),粘性表头在位,没有空白带。

⚠️ **窗口化引入的每个派生列表都必须依赖窗口。** 我把节点列表的数据源从 `layout.nodes` 换成 `shownNodes`,却忘了把它加进 `useMemo` 的依赖数组——结果是**边与时间轴跟着滚动,只有节点不动**,看起来像"滚了但内容没换"。这类 bug 在浏览器里一眼可见(四种滚动位置一测就抓到),而七套断言全都发现不了:纯模型没有错,错的是"React 有没有重算"。所以窗口化的断言只覆盖"哪些行该在窗口里",覆盖不到这一层,这一点要说清楚。

### 下一版拓扑:卡片画布(参考与实测)

泳道版证明了"关系和归属可以画出来",但它有个致命弱点:**节点是芯片,不承载内容**——一份汇报在图里只剩一个 `R-0001`。下一版改成卡片图,两个决定已经定了:

- **会话 = 框**(蓝图里 Comment 那种:带标题、底色,可整体折叠),归属靠**包含**表达,不靠位置;
- **全 canvas 渲染**(不是 SVG、不是 DOM)。

**卡片图的设计**:帧内按时间堆叠汇报卡片;线有方向与语义——主送实线(卡片出 → 对方帧的收件口)、抄送点线、共写虚线(共写者帧 → 卡片入)、线程蓝线(父卡下缘 → 子卡上缘)。**三档 LOD**:完整卡(≥0.8)→ 紧凑卡(0.4–0.8)→ 色条(<0.4),配矩形剔除。

⚠️ **一条硬规矩:卡片尺寸是 LOD 常量,绝不由文字度量决定。** 否则布局就依赖 canvas 度量、变成不可测的东西,而"缩放到全局时有多少个绘制对象"恰恰是这套设计唯一站得住的标准。文字在**绘制时**裁剪/省略。

**参考里真正可用的部分**

| 参考 | 学到什么 |
|---|---|
| Unreal **蓝图** | 卡片节点的解剖(标题栏 + 引脚 + 有类型的线)与 Comment 框做分组。(⚠️ Epic 的文档页是 JS 渲染的,抓不到正文——这部分是通行认知,不是引用) |
| **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` 只挪位置。⑥ 命中的容差靠克隆一个更粗的透明描边。 |
| **React Flow** 的性能文档 | node-graph 在规模上的共识:只渲染可见元素、memo 化自定义节点、别让视口变化触发全量重渲染。([reactflow.dev/learn/advanced-use/performance](https://reactflow.dev/learn/advanced-use/performance)) |
| Excalidraw | **双画布**:静态层与交互层分开。(这条来自一篇二次分析而非其源码,标注待确认;但理由与我们的实测一致——悬停曾是我们最大的热点) |

**为什么一个图库都不引**(实测与查证,不是好恶)

| 候选 | 事实 | 判定 |
|---|---|---|
| **konva** | MIT、**零运行时依赖**、canvas,且自带文字 wrap/ellipsis 与命中检测 | 用我们的真实用法建了探针:**+491 KB raw / +115 KB gzip**,而当前客户端半只有 89 KB raw——**5.6 倍**。换来的实际只有换行与命中两件小事(约 65 行),而**保留式场景图对我们是负资产**:架构是"纯模型 → 绘制一遍",引入场景图等于维护第二份可变几何,正是本仓库已经踩过的那类 bug(边跟着滚、节点没动)。**不引。** |
| **@antv/g6** | MIT,概念上最省(combo 就是"框",自带布局与小地图) | **11 个运行时依赖**/解包 7.6 MB/自定门槛 400 KB gzip;且 `@antv/layout` 有 **WASM 动态分块**路径——单文件插件 bundle 里动态分块会 404,与当初否掉 Mermaid、elkjs 是同一个坑。**不引。** |
| cytoscape | MIT、零依赖、canvas | 节点模型是"形状 + 标签",**做不了多行卡片**,正是新设计最在意的部分。**不引。** |
| maxGraph(draw.io) | Apache-2.0,路由/端口/泳道形状齐全 | 渲染是 **SVG**,与"全 canvas"冲突;只取几何/路由又得半个大库。**不引,但抄它的画家抽象。** |
| Excalidraw / tldraw | — | 前者是编辑器应用(渲染器不对外复用),后者生产使用需要 license key。**不引。** |

**Step 0 已落地:`src/client/cardgraph-model.ts`(96 条断言)**

先做模型、再写画布,因为这份设计要证明的全是数字:哪只帧装哪张卡、线从哪条边走、两条线落在同一条帧边上时各自落在哪、一个视口能看见什么。模型里已经钉死的规则:

| 规则 | 是什么 | 为什么 |
|---|---|---|
| **列 = 子树深度** | 同深度的帧在同一列里自上而下堆,列序即 DFS 序 | 父亲永远在孩子的左边,主送/线程天然是短横线;不需要 rank 计算 |
| **帧高 = 标题栏 + 内边距 + 卡片堆** | 空帧也保底一个最小高度 | 空会话仍然读得出一只框,而不是消失 |
| **卡片刻度 = LOD 常量** | 文字只影响绘制,不影响几何 | 见上面的硬规矩;实测断言:400 字主题与 3 字主题拿到**同一个矩形** |
| **输入顺序无关** | 卡片按 `created` 排,平局用 report id;连边也按卡片序走 | "同一本账,同一张图",打乱输入重建 byte 相同 |
| **收件口均分** | 同一帧同一边的线,按**远端 y** 排序后在卡片带内等分 | 两条线永不落在同一像素,且不会在口子上互相交叉 |
| **同列走沟槽** | 同列两只帧之间的线,从卡片侧边出去、沿栏间沟槽、再进对方同侧 | 直线会**横穿它自己落地的那只框**(这是实测 dump 里发现的,不是设想),读起来像"这条线属于那只框" |
| **跨列是直线** | 跨列的线直接连,可能穿过中间的帧 | 这是这一版接受的代价:细线 + 箭头叠在框上,和 draw.io 一样。真正的绕障要图算法,正是我们不要的东西 |
| **矩形剔除取代窗口化** | 一切带 `bounds` 的东西(帧/卡片/线)用同一个 `cullByBounds` | 自由画布没有"行"可数;且**两端都在屏幕外、中段穿过视口的线必须留下**——泳道版为这条踩过坑,断言里复现了它 |
| **视口反变换** | `worldViewport` 把 pan/zoom 换成绘制坐标里的可见矩形 | 坏掉的 `scale`(0/NaN)退化成 1,绝不产生无限或反向矩形 |

现在**没有任何视图引入这个模块**,所以它对运行时是零成本:重建前后 `lib/client.js` 都是 **89,360 B**(实测,未变)。它由 `scripts/cardgraph-check.ts`(96 条断言,与另外六套一起进 `pnpm test`)钉住。

**Step 1 已落地:`src/client/cardgraph-painter.ts`(78 条断言)**

画家只认一个**结构化上下文** `PaintContext`——十来个方法(填充、路径、文字、一个变换),真 canvas 天然满足它,测试里换成一只**记录器**。于是"这条线被淡出了、那只框没有""画的是虚线还是实线""箭头落在哪个点"全都成了断言,不用开浏览器。这也是 draw.io 的画家抽象真正值钱的地方:换成 SVG 只是再实现一遍这个接口。

| 决定 | 是什么 |
|---|---|
| **绘制顺序:帧 → 线 → 卡片** | 线永远压在它穿过的框上(不会消失),而卡片的文字永远不会被线穿过 |
| **文字只在这里量** | `foldText`/`elide` 按 `measure` 折行:Latin 能在空格处断就断,中文按字断;按 (字体, 文本) 缓存,因为画布上每次测量都是一次同步排版 |
| **几何一点不看文字** | 断言把测量宽度从 1 改到 40:画出来的矩形与线段**逐字节相同**,而文字确实变了 |
| **主题走 token** | 15 个颜色槽 + 4 个字体 token(每个自带 size/weight/line-height/family),全部有兜底;重读靠一只**金丝雀 token**(页面底色)——它没动就不重读,于是每次重绘只多一次属性读取 |
| **状态与线型同源** | 卡片左缘色条、状态文字、四种线的虚实都取自同一张表,和图例、列表的 `Tag` 语气一致 |

⚠️ **已知未做:坐标不做像素对齐。** draw.io 是在变换之后取整的,因为它的画布是 1:1 屏幕空间;我们的画布是缩放过的,要取整就得逐点换算到屏幕空间。先按抗锯齿走,等实测说糊了再补——这条留在 README 而不是留在代码注释里,是因为它需要一个浏览器里的判断。

**Step 2 已落地:`src/client/CardgraphView.tsx`——在真实浏览器里逐条量过**

标签页现在渲染卡片画布。两只画布(静态 + 交互)、平移缩放、命中、LOD 跟随、点开详情全部做完,并且在**隔离实例 + 真实浏览器**里逐条验证(不是推理):

| 验的是什么 | 结果 |
|---|---|
| 真的画出来了吗 | 底图 `946×420`、100% 不透明;像素里数到文字(8,976 个暗像素)、橙/绿状态色、蓝色线程——四种线型都落了墨 |
| **悬停不重绘底图** | 悬停前后底图墨量**完全相同**(397,320);交互层从 0 变成有内容。这就是两层的意义 |
| 悬停的代价 | 中位 **32.6 ms**,而每次采样等两帧的地板是 ~33 ms(60 Hz)——也就是**测不到开销**,与泳道版当初撞到的同一条地板 |
| 点击卡片 | 打开下面列表里的详情(`手工核对几何 dump` 出现在 DOM 里)——图表不另开阅读面 |
| 拖拽 | 平移了(按钮的 `left` 变了),**且没有触发打开**——拖动与点击靠 4px 阈值分开 |
| 滚轮 | 以光标为中心缩放(80% → 100% → 80%),`window.scrollY` 始终为 **0**——页面不会在缩放下滚动 |
| LOD 真的跟着缩放 | 卡片档 **8,976** 暗像素 → 芯片档(26%)**301**(只剩帧标题)→ 125% 时 **13,200** |
| 无障碍 | 7 张可见卡片各有一个**真按钮**(透明、带 `aria-label`),键盘/读屏可达;列表仍是完整路径 |
| 热重载 | 重建 `lib/client.js` 后浏览器**自己换掉**了插件(页内不刷新),这就是改完立刻能看到的原因 |

**三处是看了截图才改的**(不是想出来的):

1. **首屏不是"适应窗口"。** 一开始沿用 fit,结果是 **57%**——落在紧凑档,读者第一眼看到的是一堵单行卡片墙,而卡片画布的卖点恰是被省略掉的主题。改成**按宽度适配、且不低于卡片档**(`clamp(…, 0.8, 1)`):暗像素 **1,743 → 8,976**,帧、列与可读卡片同时在场。"适应窗口"按钮仍然保留,用来看全貌。
2. **遮罩从 0.18 改到 0.55。** 第一版照搬泳道版的淡出值,截图一看:其它帧、标题、卡片**全都读不出来了**——追一条路径的代价是把整个上下文赔进去。泳道版自己早就得出过同一条结论的另一半(*该退到后面的是关系,不是参与者*),而画布遮罩比逐个形状淡出更粗暴,所以必须更轻。现在聚焦路径在顶上全强度重绘,其余保持可读。
3. **计数说的是"8 个框"而不是"8 个会话"。** 共享的"树外"帧也是一个框,把它算成会话就是在说假话。

⚠️ **顺带修掉一个自己在代码里埋的雷**:滚轮处理原先在 `setZoom` 的 updater 里调 `setOffset`——那是"在 updater 里做副作用",React 有权把 updater 调用两次,于是每一格滚轮位移会被应用两遍、缩放会从光标下漂走。改成用 ref 读当前变换、在 updater 外面算。

代价:整个卡片画布(模型 + 画家 + 视图)让客户端半从 **89,360 B → 114,955 B**(gzip 24.86 → 32.17 kB),**+7.3 kB gzip**——而当初被否掉的 konva 光是库自己就要 **+115 kB gzip**。泳道版的 DOM 视图随之下线,这部分是被它自己腾出来的地方抵掉的。

**留下什么、重写什么**:泳道序(成为帧的排列序)、树外泳道(成为"树外"帧)、四种边的语义、窗口化(升级为矩形剔除)、单层淡出、合并路径、marker 箭头、图例、悬停文字——**全部保留**;泳道的"列 + 行 + 芯片"重写成"帧 + 卡片 + 线"。列表、详情面板、过滤、轮询完全不动。

### 线程、时间基准与阅读上限

- **线程跳转**:详情面板显示该汇报的**上溯**(`parent`)与**下递**(`children`),点任意编号即跳到那一份。链接可以指向**当前树之外**的汇报(它属于另一条线),此时详情仍会打开——`report` 端点按账本范围而非子树范围查询——并明确提示"不在当前会话树的列表里"。
- **时间基准**:一键在「按创建 / 按最近活动」之间切换。会话始终按创建时间;切换只影响汇报在时间轴上的落点。
- **任务标签即分组**:卡片上的 `task` 标签**可点击**——点它就把搜索框设为该标签,于是同一次协作(可能横跨多条会话树)被拉到一起。这是刻意的实现选择:**复用已有的搜索,不引入第二套筛选状态**;`report_list({task})` 在工具侧提供同一维度的分组,并把该任务的 open/acked/closed 统计一并返回。点它还**把状态芯片清回「全部」**:它的提示语承诺的是「只看任务 X」,而只要还有一个状态芯片在收窄,这句话就是假的——实测先筛「已结案」再点任务芯片,原本会得到一句「显示 0/7」,让人以为这个任务没有汇报。
- **正文明限**:面板只显示正文开头(>4000 字时截断并提示),与 `report_read` 给模型的上限**保持一致**——让人类视图与模型视图被同样地约束,双方都不会对对方看到的范围感到意外。**范围一致,排版不同**:正文用 shell 自己的 `MarkdownText` 渲染,所以标题、列表、表格、代码块在这里与在对话标签页里长得一样(代码块还带「复制」按钮),而模型拿到的仍是同一段纯文本。**面板里没有第二个滚动条**:正文已经被这个上限约束住了,再套一个 320px 的内层滚动只会在时间线自己的滚动之上抢滚轮;面板随内容变高,滚动统一交给外层。
- **截断 Markdown 不等于截断文本**:切点落在代码块中间会留下一个没有闭合的围栏,等于把「代码块到哪里结束」交给渲染器去猜。实测这个渲染器猜得对(切在围栏中间会渲染成一个正常闭合的代码块,没有尾随痕迹),所以 `closeOpenFence()` 是**便宜的保险而不是修一个看得见的坏**——渲染器是插件不拥有的平台模块,它的宽容不是契约,补一个围栏让输出在任何渲染器下都是合法 Markdown。它共 5 条断言,且只在正文真被截断时才跑。
- **两种"看不见"分得很清**:汇报**不在树里** vs 汇报**被当前筛选隐藏**——两组措辞不同。把后者说成"可能属于另一条线"是错的,所以两种情形各有各的话。

### 数据通路

浏览器一个请求取全部数据:

| 端点 | 返回 |
|---|---|
| `GET /api/report-ledger/timeline?root=<sessionId>` | 该会话的**递归子树**(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略 |
| `GET /api/report-ledger/report?id=<R-0001>` | 单份汇报的缩略 + 完整路径 + 待送达集合 + 正文 |

子树由 `ctx.sessionQuery.listSessions()` 的 `parentSession` 链走出,标题只对**子树内存活的会话**查询(长寿命部署里语料远大于一次协作,而标题是装饰、树不是)。汇报按"子树任一成员参与过它的路径"过滤(发起、主送、抄送、共写),因此这是**协作账本**而不是全库倾倒。

为什么不用 typert 生成的 Remote:那需要构建期代码生成。第三方插件的通行做法是自建同源、仅限 loopback 的 JSON 通道,本插件照此实现。**两半共用 `src/shared/wire.ts` 的类型**——该文件只有 `export type`,会被完全擦除,所以浏览器 bundle 不可能内联宿主代码(构建后已核验:外部依赖仅 `react`、`react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives`,node 内置模块与 yaml 均为 0 命中)。

那个平台模块的类型是**声明出来的,不是导入的**(`src/client/platform.d.ts`):它只存在于 shell 自己的 bundle 里,不在 profile 的 `node_modules` 层——而 `tsconfig` 的 `@deepseek-ai/*` 正映射到那一层。这与 `client/index.ts` 给 `slots` / `locale` 写结构化接口是同一个做法,理由也一样:**断言我们实际调用的形状,不多声明一个字段**(多声明的字段在运行时会被静默忽略)。

**视图会自己跟上。** 账本是别的 agent 在后台写的,所以只取一次的快照会在最需要它的那一刻过期:打开期间每 10 秒重取一次时间线,页面不可见时跳过(后台标签页零成本),切回该浏览器标签页时立刻补一次。「刷新」按钮保留,并且**只有它**会连已打开的详情面板一起重取——轮询刻意不碰详情,否则正文每 10 秒闪回一次「正在读取传递路径」。两个计数器(`timelineNonce` / `detailNonce`)就是为这个区分存在的。

### 守卫与信任假设

两个端点都是 GET、只读、无写入面(所有变更仍只走模型工具,那是唯一会记录跳的路径)。守卫照实复刻部署中第三方插件的做法:**TCP 对端必须是 loopback** + **`Host` 必须解析为自身且是 loopback 主机名** + **浏览器同源标记一致**。第二项挡掉 DNS rebinding 拼法(`localhost.attacker.tld`)与非规范权威(默认端口 `127.0.0.1:80` 解析后会消失,故不相等)。

⚠️ **信任假设要说清楚**:实测发现**已注册的 exact 路由先于鉴权匹配**——未注册路径返回 401,而注册过的路由直接 200,不要求会话 cookie。所以守卫是这些端点**唯一**的防护,其信任边界是"本机进程",与账本文件本身可被本机读取是同一信任级。反向代理部署需要守卫的共享令牌变体;只服务直接 loopback 是安全的默认,失败模式是"读被拒绝"而非"读被泄露"。

## 同伴:代理自主开启会话

两个工具:

| 工具 | 作用 |
|---|---|
| `peer_list` | 列出你能对话的代理及其与你的关系(上级 / 下属 / 兄弟 / 你开启的同伴 / 账本里有往来的联系人),并标注谁此刻在线 |
| `peer_start` | 开启一个**独立同伴会话**,并把任务作为一份**汇报**交给它 |

### 同伴是独立根会话,不是下属

这是本阶段最重要的设计判断。`peer_start` 创建的会话**没有 `parentSession`、没有 `origin`、`delegationDepth` 为 0**——它是一个根会话。这样它才配得上"同伴":拥有自己的生命周期与预设、不占用任何委派深度预算、出现在工作区会话列表里、**并且比开启它的那一轮活得更久**。

代价是血缘无法表达这段关系(`parentSession` 是空的),所以**名册日志**(`$DSH_HOME/report-ledger/peers.jsonl`,append-only)记录它:谁开启了谁、何时、什么名字、继承的工作目录。这条记录同时是**授权凭证**。

### 授权规则

DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、teams、workflows、hosts 保持拒绝,**直到有一个显式的授权协议有生产消费者**"。本插件就是那个消费者,它实现的规则故意收得很窄:

**血缘授予通道,开启过的会话授予通道——而"仅仅在账本里有往来"不单独授予通道。**

最后一条是刻意的:正因为"通过账本联系对方"是建立接触的方式,把接触本身当作授权就形成了循环——先有鸡还是先有蛋。所以**主动伸手(`report_send`/`report_cc`)永远允许**(它会产生那条记录),而**直接通道只来自血缘或"我开启了它"**。

创建会话是本插件唯一会**新增活代理**的能力(其余都只是记录),所以它的授权故事是叠加的:工具可见性(DSH 自己认定的唯一真实闸门)+ `maxPeersPerAgent` 预算(默认 8,超限是明确的工具错误而非静默拒绝)+ **继承调用者自己的工作目录**(无法被指向无关目录树)+ 每次创建都在名册里留痕。

### 创建与对话是分开的

`peer_start` 只负责创建与记录;**交任务由调用者用一份汇报完成**(工具层组合两者)。于是:任务天然进了账本、同伴被投递唤醒、路径被记录,而同伴之后对同一份汇报的 `report_contribute` 就让它成为双向线程。这正是"用汇报做交互的关键"——S4 **没有**引入第二条轻量消息通道,因为那会产生一条不受审计的旁路,正好抵消 S1 的全部价值。

### 一个必须记住的 API 陷阱

`AgentHandle.dispose()` 会"停止循环、注销代理、**并从 store 里移除该会话**"。所以对"必须比这一轮活得更久的同伴"**绝不能**持有或自动释放 handle——本插件创建后即丢弃 handle,同伴通过 `ctx.agents` 保持可寻址。自动 dispose 会删掉同伴的会话。

### 一处已知限制

在**一次性 headless 运行**里,开启的同伴不会真的执行任务:它不是子代理,因此不在运行器 drain 的范围内,父任务一结算进程就退出了。任务本身不丢——投递已作为 `agent/inbox/spliced` 进入同伴的会话日志,会话恢复时它就在历史里。在长期运行的 web profile 中同伴会正常处理收件箱。

## 工程约定(踩过的坑)

- **绝不新增自定义会话事件类型。** `SessionEventMap` 看起来可扩展,但持久化读取路径 `assertEventsSupported` 只在 `KNOWN_SESSION_EVENT_TYPES` 命中或事件带 `ignorable: true` 时放行,而 `Session.append` 从不设置 `ignorable`;白名单是从仓库内成员生成的字面量。追加新类型会让**该会话日志在重载时不可读**。因此账本走文件,模型可见的摘要走 `source: {kind:'plugin', plugin:'report-ledger', form:'relay'}`(既有已知形状)。
- **官方包必须保持 external。** 内联 `dsh-tools` 会复制服务注册表、破坏实例同一性。构建只内联真正的第三方依赖(`yaml`)。
- **运行时解析需要 `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` 可能清掉它,重装后需重建。**
- **工具参数规范中不能写 `required: false`。** `defineTool` 的 `ParameterSchemaSpec` 只接受 `required: true` 或**整个省略**,写 `false` 会在加载时报 `required must be true when present`。可选参数就是不带 `required` 的字段。
- **投递与账本分层。** `deliver()` 只做传输、绝不碰账本;到达跳由 `ReportService` 统一写入,保证审计的写者唯一。
- **可选服务用 `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) => …)` 同时满足两者——可选,且与到达顺序解耦。
- **`ctx.get('webServer')` 的失败是静默的**,所以凡是通过 `ctx.get` 拿可选服务再"可用则注册"的地方,都必须有一个能在真实 profile 里被观测到的验证手段,否则这类 bug 只会在浏览器里表现为一个空标签页。本项目靠独立 web profile 的 HTTP 断言抓到它。
- **每个副作用都必须在 `apply` 的 `ctx.effect` 里注册。** 在**工具执行体内部**直接调用 `ctx.webServer.register(...)` 并把 disposer 存进闭包,是一个真实的陷阱:该副作用不归插件 fiber 所有,`cordis_stop` 与 `cordis_undefine` 都无法回收它,只能靠重启进程清除(本项目在诊断探针上踩到过一次,正式插件的路由因此写在 `ctx.effect` 内)。
- **手改账本不能让记录消失。** YAML 的严格默认会把**重复键**判为错误,而重复键正是手改时最容易出现的情况(追加一个已存在的字段)。那会让整份文档解析失败、汇报从账本里静默消失。所以读取路径用 `uniqueKeys: false`(后者胜),而"无 front matter""未闭合块"这类真正无法解释的输入仍然拒绝。丢失记录远比一个歧义键被可预测地解决严重。
- **锚在列表项上的面板,必须保证那一项存在。** 详情面板原本渲染在汇报**行内部**,于是当目标不在(筛选后的)列表里时,面板无处渲染、连同里面的提示一起消失。修法不是把面板抽出来,而是**为被打开的汇报补一行合成行**——这同时修掉了另一个我还没发现的同类缺陷:**在详情打开时改变筛选,面板原本也会消失**。
- **补出来的那一行要插进它自己的时间位置,不能追加在末尾。** 追加会让 11:12 的汇报显示在 14:30 的汇报下面——偏偏用户正处在「为什么少了一条」的语境里,一个看着像排序坏了的列表比一片空白更误导。落地做法是把排序的比较函数抽出来给插入复用:**排序与插入用同一个比较器**,两份实现一定会漂移。这一条同样钉在测试里(插入后列表仍有序、且新行不是最后一个)。
- **不要把「整行可点」实现成 `role="button"` 的行。** 行里还有一个任务芯片——一个真的 `<button>`,于是构成嵌套交互内容;更糟的是 `role="button"` 的可访问名**由整棵子树计算**,读屏会把一整行 uuid 念一遍、再把任务标签念第二遍。改法是让行回归普通容器(点击保留为鼠标便捷路径),把展开动作交给左侧箭头:真按钮、名字说清它做什么(`展开 R-0001`)、`aria-expanded` + `aria-controls` 指向它打开的面板(面板带 `id`)。这样每行两个 Tab 停靠点是**两个真实动作**(展开、按任务筛选),而不是同一个动作的两次。⚠️ 箭头**必须 `stopPropagation`**:它和行处理的是同一个动作,不拦住就会切换两次,表现成「点了没反应」——这一类 bug 在自动化里显示为 `aria-expanded` 从 false 变回 false,肉眼则完全看不出区别。
- **平台组件的枚举要去 CSS 里确认,不能只按已发布视图的用法推断。** `Tag` 的 tone 在平台代码里只出现了 `solid` 与 `neutral` 两种,照此推断就会以为状态只能用灰阶——而 CSS 里其实定义了 8 个。**能渲染的取值集合由 `[data-tone=…]` / `[data-state=…]` 的样式规则决定**,代码里没用到只是没用到。
- **这些组件不接受 `style`,只接受 `className`,而且落在它们自己的 wrapper 上。** 于是「让搜索框占满工具栏」这件事只能靠一条 CSS 解决,而那条规则的类名必须是**我们自己的**(`report-ledger-search`),不能去写 `_wrap_1g6ru_1` 这类 hash 类名——同一条规则挂在 hash 类名上,就是给自己埋一个随 shell 版本爆的雷。
- **接在早退块里的东西可能要不到。** 上面那个"不在当前树里"的提示原本嵌在线程块的 IIFE 内,而该块在汇报没有上溯/下递时会提前 `return null`——偏偏"树外汇报没有线程链接"正是常见情形。条件渲染里的早退会静默吞掉同一块里其它独立的内容。
- **`whiteSpace: 'nowrap'` 的样式对象不能复用到「长度由数据决定」的单元格上。** `time` 这个样式对象本来只描述时间戳,却被顺手复用到了传递路径的收件人/备注列上;而 `1fr` 网格轨道的自动最小尺寸就是 min-content——对一整行不可断行的文本来说,那就是整行宽度。结果网格宽过自己的面板、整个标签页多出 426px 横向滚动,备注被切在屏幕外,**而那个滚动条只在标签页的最底部才够得着**(容器被外层撑到 1314px,横向滚动条在它自己底部)。同一类问题在 flex 项目上表现为「不写 `min-width: 0` 就永远缩不下去」,于是汇报行的 meta 行、账本路径、待送达列表是同一个毛病的三处实例。修法是分成两个样式:时间戳保持 nowrap,内容一律 `whiteSpace: 'normal'` + `minWidth: 0`;需要保持单行密度的地方用 `overflow: hidden` + `textOverflow: 'ellipsis'`,而不是让它去撑破容器。
- **在浏览器里量,而不是在浏览器里看。** 上面那条缺陷在截图里只是"文字好像被切了",`getBoundingClientRect` 与 `scrollWidth/clientWidth` 一量就是精确的一句话:网格 1052px、单元格右边缘 1478px、shell 溢出 426px。凡是"布局被内容撑破"这一类,肉眼只能给出怀疑,测量才给出结论。

## 开发与验证

```sh
pnpm build        # 产出 lib/index.js(host 半)与 lib/client.js(client 半)
pnpm test         # 八套确定性检查共 536 项断言:账本内核、生命周期与任务分组 78 + 提示词角色分流 47
                  # + 时间线装配与路由守卫 59 + 同伴名册与创建 42
                  # + 时间线模型(筛选/线程/空状态/身份/正文/围栏)81
                  # + 拓扑布局(泳道/节点/边/树外/线程路由/确定性)51
                  # + 卡片画布模型(分档尺寸/帧装箱/收件口/沟槽/剔除/视口/拾取)96
                  # + 卡片画布画家(绘制顺序/裁剪/虚线/箭头/遮罩/主题/折行/度量缓存)82
                  #(不需要 DSH,不触碰真实账本,不启动服务器)
pnpm typecheck    # 对部署中的 harness 类型做全量类型检查
```

**安装到 profile**(本仓库已这么装好):包经 junction 出现在 `~/.dsh/profiles/node_modules/dsh-report-ledger`,并在 profile 的 `cordis.patch.yml` 中有一行:

```yaml
- insert:
    - id: report-ledger
      name: 'dsh-report-ledger'
      config:
        announceToAgent: true
```

**开发回路:**

- **宿主半:保存即生效。** web profile 的 `cordis.patch.yml` 里把 `dsh-base` 默认禁用的 `hmr` 行打开,并把 `root` 扩到本仓库的 `lib/`(因为插件经 junction 挂载、真实路径在 profile 之外)。配合 `pnpm watch`,回路是:**保存 → tsdown 重建(约 0.2s)→ HMR 就地重载该插件条目**。进程不重启、端口不断、正在进行的会话不中断。
  - 已实测确认:改 `lib/index.js` 后新代码即刻生效,**且宿主进程 PID 不变**。本插件被判定为"直接变更"走局部重载,不会触发 `loader.exit()`(那是 **CLI 入口静态依赖树**里文件改动才会走的路径;插件由 Loader 动态 `import()` 加载,不属于那棵树)。
  - 重载是安全的:插件的持久状态全在磁盘账本上,内存里只有一个互斥锁表,重载不丢数据。
  - ⚠️ **启用 `hmr` 需要一次重启才生效。** 通过 `patchReload: live` 在运行中启用只会"启用行"而**不应用 `config`**——实测服务自己报 `root: []`(空监视)与 schema 默认 `debounce: 100`。组合树本身是对的(`dsh --profile web --dump-config` 可见完整 config),只是生效时机问题。
- **客户端半:保存即生效,同样不需要刷新页面(已实测)。** 原先这里写的是「重建 + 页面刷新」,并注明"未验证"——**那条是错的**。`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 之外**不影响**这条链路:轮询的是解析后的真实路径。
  - 实测方式与结果:直接改写插件 `lib/client.js` 里的 `"view.tab"` 字面量(不重建源码、不刷新、不点击),标签在**约 1 秒内**变成新值;改回去又自动回退。两次 `performance.getEntriesByType('navigation').length` 始终为 1,页面没有重新导航。也就是说 `pnpm watch`(或任何写 `lib/client.js` 的构建)对客户端半就是**完整回路:保存 → 重建 → 页面自己换掉这个插件**。
  - 代价与边界:换掉的是**插件**,插件内的 React 状态会丢(展开的详情会收起),而会话、工作区与连接状态不受影响;重载失败不回滚,该 entry 停在 FAILED 视图并在下一次 `rebuilt` 帧从头重试。
  - **仍然需要刷新页面的只有一种情况:启动图本身变了。** 装/卸插件、启用/禁用某一行(即 `dsh.client` 名单变化)只在页面加载时组合——每个 `rebuilt` 帧只携带单个插件产物的 revision,不替换启动图。
- **`pnpm watch` 的生命周期**:它是个前台常驻进程。由代理会话启动的那种只在该会话存活期间有效;要长期常驻请在自己的终端里跑。
- 离线/批量集成验证仍可走独立的 headless profile(`~/.dsh/profiles/reports-dev/`),它一次性跑任务、不干扰正在服务的 GUI:
  ```sh
  $env:DSH_REPORT_LEDGER_ROOT = "$env:TEMP\report-ledger-it"
  dsh --profile reports-dev "<task>"
  ```
  该 profile 的补丁里同样把 `hmr` 打开并把 `root` 扩到 `lib/`。
- **验证浏览器侧能力时,在隔离的 DSH_HOME 里另起一个 web profile**,不要动正在服务的那个:DSH 明确声明两个 harness 进程不协调共享同一持久化 store,共用会威胁正在运行的实例。
  ```sh
  # 只把包解析层 junction 进去,会话/账本留在临时 home 里
  $iso = "$env:TEMP\dsh-web-test"
  mkdir "$iso\profiles"
  cmd /c mklink /J "$iso\profiles\node_modules" "$env:USERPROFILE\.dsh\profiles\node_modules"
  # 在该 home 内建一个 bundles = [dsh-base, dsh-web-app] + 本插件行的 profile
  $env:DSH_HOME = $iso
  dsh --profile <你的-web-profile> --port 3099 --no-open
  ```
  启动会打印一个带 `?token=` 的 URL —— **web profile 用 URL token 鉴权**,带上它就能让自动化浏览器登录这个隔离实例,从而验证真实渲染。用完**先删 junction 再递归删除**临时 home,否则删除会顺着 junction 冲进真实 profile 层。
  另外:**已注册的 exact 路由先于 `/api` 鉴权栅栏匹配**,所以自查端点时可以不带 token 直接 curl。
- 补丁语法:插新行用 `- insert:`;**按 id 修改已有行必须写成顶层 `- id:`**,把已有行放进 `insert` 会新建一条同 id 的行并报 `duplicate loader entry id`。

### 发布(维护者)

分发形态是**预构建的 bundle**:`lib/` 在发布前构建好,用户安装时不跑任何构建脚本,因此不需要 `allowBuilds` 授权。

```sh
pnpm check          # 类型检查 + 五套确定性检查
pnpm pack           # 先出 tarball 核对产物(prepare 会顺带构建)
npm publish         # ⚠ 本机 registry 若是镜像站,必须显式 --registry=https://registry.npmjs.org
```

`pnpm pack` 的产物清单**缺一项的表现都是「装上了不生效」而不是报错**,逐条核对:

| 检查项 | 本包取值 |
|---|---|
| `main` / `exports` 指向构建产物而非 `src/` | `lib/index.js` / `lib/client.js` |
| `files` 含入口**与 `cordis.patch.yml`** | `["lib", "cordis.patch.yml", "THIRD-PARTY-NOTICES.md"]` |
| `dsh.bundle.patch` 指向该 patch | `./cordis.patch.yml` |
| `version` 已递增 | npm 不允许覆盖已发布版本 |

发布后**在干净环境里验证**(本仓库已按此验证过 0.1.0 的 tarball):

```sh
dsh plugin --profile demo add dsh-report-ledger   # 空 DSH_HOME 里
dsh --profile demo --dump-config                  # 应出现 `# == dsh-report-ledger` 这一层
```

`dsh plugin add` 会因包声明了 `dsh.bundle` 而**自动**把包名追加进 `dsh.profile.bundles`,用户不需要手改配置。

**CI 发布(可信发布 OIDC):** `.github/workflows/publish.yml` 负责推 tag 后自动发布——不需要任何 npm 令牌,
也不需要手输一次性验证码,并自动附带 provenance。**首次启用前要在 npm 侧建立一次信任关系**(需交互式 2FA):

```sh
npm trust github dsh-report-ledger --file publish.yml \
  --repo stone-brick/dsh-report-ledger --allow-publish
```

`--file` 必须与工作流文件名完全一致。之后发版就是 `pnpm version patch && git push origin main --follow-tags`。
注意 CI 只跑 `pnpm test` + 构建,**不跑 typecheck**:类型来自 profile 的官方包层(见下文 junction 一节),
CI 里没有这一层,把官方包装成 devDependency 反而会在工作区复制服务注册表、破坏实例同一性。

首次发布(新包名)只能手工来一次:npm 的**可信发布与暂存发布都要求包已存在**,新包名两者都会 404。
手动发一次时如果是安全密钥账号,`npm publish` 会打印一个 `https://www.npmjs.com/auth/cli/…` 链接,
在浏览器里完成认证即可(放行凭据用 `--//registry.npmjs.org/:_authToken=…` 传,别写进 `.npmrc`)。


**git 安装与 npm 安装不是一回事**:`add github:<你>/dsh-report-ledger#<sha>`(或 Gitee 地址)拉到的是**源码**,
靠仓库里的 `prepare` 构建出 `lib/` 才能跑——本仓库实测在 pnpm 10.14 上直接通过、未要求 `allowBuilds`,
但部分 pnpm 版本会拦截依赖的构建脚本,届时 `dsh` 会打印出要写进 profile 的 `pnpm-workspace.yaml` 的包键。
**对外仍推荐 npm 安装**:预构建产物、不触发任何构建脚本、不需要授权。

#### Gitee 镜像

Gitee 自带的「仓库镜像管理」在本账号不可用(`GET /api/v5/repos/{owner}/{repo}/mirror` 返回
`404 Not Found Project`),所以同步方向反过来:GitHub 主动推。

- 工作流 `.github/workflows/mirror-to-gitee.yml`,在 `main` 与 `v*` tag 的 push 后镜像 `main` + tags;
- 凭据是 GitHub 仓库 Secret `GITEE_TOKEN`(Gitee 私人令牌,只需 `projects` 权限)。
  **令牌有有效期,过期后要重新生成并 `gh secret set GITEE_TOKEN`**,否则工作流会认证失败;
- 令牌只经 `credential.helper` 按需交给 git,不写进 remote URL,所以不会落进 `.git/config` 或命令输出。

发行版附件**不在自动同步范围内**,需要单独上传;注意附件接口要求令牌放在 **query** 上,
放 form 里会得到 `401 登录失效`(实测):

```sh
curl -X POST "https://gitee.com/api/v5/repos/stone_zhan/dsh-report-ledger/releases/<release_id>/attach_files?access_token=<token>" \
     -F "file=@dsh-report-ledger-0.1.0.tgz"
```

## 已知限制

- **单进程假设。** 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。
- **正文并发覆盖。** 跳流 append-only 永不丢跳,但两个人同时改写同一份正文是后写者胜(`report_contribute` 用追加,规避了常见路径)。
- `fromName` 取自会话标题,是"会话名"而非"代理名"。

Install

dsh plugin --profile web add github:stone-brick/dsh-report-ledger#6f614b2834144e2478c6888fadc0fbd81f1c8e14

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
Source