Uloboros/dsh-design-ledger

dsh-design-ledger

Turn design documents into a live development progress tree and inject it into DSH session context, so new sessions can pick up where the last one left off.

未分類deepseek-harnessdesign-documentdeveloper-tools

dsh-design-ledger

中文 · English

把设计文档变成一棵可实时更新的开发进度树,并让 DSH 代理在每次会话中自动接上进度。

面向场景:大型项目(游戏 / 软件)在同一个工作区长期开发,单个会话的上下文会被耗尽。本插件把"设计文档 → 进度清单 → 代码索引"变成可持续、可查询、可注入的结构化数据,使新会话能接着干。

30 秒理解

设计文档(.md / 文件夹)          开发进度树(本插件生成与维护)
┌──────────────────────┐          ┌──────────────────────────────────┐
│ 03_systems/          │  解析    │ index.json   索引(自动注入)      │
│   S01_core_gameplay/ │ ───────► │ systems/     每个系统的完整子树    │
│     design.md        │          │   S01_....json                   │
│   S02_base_building/ │          │ state/       易变进度(可 gitignore)│
│     design.md        │          └──────────────────────────────────┘
└──────────────────────┘                        │
                                                │ 注入
                                    ┌───────────▼───────────┐
                                    │ DSH 会话上下文          │
                                    │ · 任务提示词           │
                                    │ · 索引(系统 + 完成度) │
                                    │ · 当前聚焦子树         │
                                    └───────────────────────┘

为什么不是"把设计文档全塞进上下文"

实测一份真实设计文档:19 个 .md / 588 KB / 226,885 字符 ≈ 133,000 tokens。

内容 · 体量 · 能否每次注入 任务开启时的提示词 · 几百字符 · ✅ 可以 整个设计文档 · ≈133k tokens · ❌ 不可行(单份系统文档就 ≈65k) 进度索引 · 数 KB · ✅ 可以 当前聚焦子树 · 数 KB · ✅ 可以 其余子树 / 代码索引 / bug 明细 · 大 · ❌ 按需读取

所以注入策略是分层的:索引常驻、聚焦子树常驻、其余按需。这不是妥协,而是唯一可持续的做法。

注入策略(用户已确认)

层 · 内容 · 时机 L0 · 任务开启提示词(完整) · 会话首次装配时注入一次 L1 · 索引:系统清单 + 完成度 + 文件指针 · 每次会话注入 L2 · 当前聚焦子树(优先 marked 标记的节点) · 每次会话注入 L3 · 其余子树、代码索引、bug 明细 · 不注入,由 design_ledger_read 按需取 L4 · 设计文档原文分片 · 不注入,由 design_doc_read 按需取

注入走 system-prompt/assemble(waterfall)——与 dsh-agent-instructions 注入 AGENTS.md 同一接缝。

进度树的数据模型

每个节点是一行"表项",字段固定,便于工具解析与你手改:

字段 · 含义 id · 稳定标识(按层级派生,如 S02/模块A/功能1) name · 节点名 = 设计文档里的名称(无名称时按功能/用处命名) kind · system / subsystem / feature / subfeature / task status · todo / doing / done / blocked / dropped parentId / children · 父子关系(构成树) designRefs · 设计文档索引:{ file, heading?, anchor? } codeRefs · 代码索引:{ file, symbol }(只记文件 + 符号名,不记行号) interfaces · 对外接口/契约 bugs · { id, summary, status, codeRef?, designRef? } notes · 备注 marked · 是否被标记为"当前聚焦"(注入优先)

为什么代码索引只记"文件 + 符号名":行号会随编辑漂移,符号名稳定。"几到几行"这类信息由编辑器/IDE 提供,不写进台账。

⚠️ 开发这个插件时踩过的坑(必读)

这些真的把 DSH 弄到起不来、或让功能静默失效/显示错乱,而且原因都很隐蔽。改本插件时请先读这一节。

崩溃 1:客户端 apply() 里调了不存在的 API → 宿主侧栏一起挂

Uncaught TypeError: ctx.locale.get is not a function
Error: web boot: 1 entry did not activate
@deepseek-ai/dsh-client-ui-sidebar: failed      ← 失败的是宿主自己的条目

机制:客户端半区的 apply() 里任何未捕获异常都会让宿主把该条目判为"未激活",并且连带把依赖它的宿主条目(这里是 dsh-client-ui-sidebar)一起判失败,于是整个 Web 启动失败、应用起不来。

根因:ctx.locale 没有 `get` 方法。真实 API 只有:

API · 签名 · 说明 ctx.locale.register(ns, dict) · (ns: string, dict: LocaleDict) => () => void · 注册字典(dict 是扁平 Record) ctx.locale.bind(ns) · (ns: string) => Translate · 取翻译函数,调用时读当前语言

修复与纪律:翻译走 bind + 本地兜底;并且

  • apply() 内每一个可能失败的动作都各自 try/catch;
  • 最外层再包一层兜底 apply(),保证永远不向外抛(client/client.js 的 apply / applyInner 分工就是为此)。

崩溃 2:导出没有 schema 的 Config → cordis 调用 .validate 抛错

TypeError: Cannot read properties of undefined (reading 'validate')
  at resolveConfig (cordis/lib/index.js:958)

机制:cordis 的 resolveConfig 是这样的:

function resolveConfig(runtime, config) {
  if (!runtime.Config) return config          // ← 没有 Config 就直接返回
  const result = runtime.Config['~standard'].validate(config)   // ← 否则要求 schema
  ...
}

所以要么导出带 `~standard` 的 schemastery schema,要么完全不导出 `Config`。导出一个普通对象(我为了去掉 schemastery 依赖而这么做过)就会命中 .validate 抛错、bundle 激活失败。

当前做法:不导出 Config,默认值放在内部 DEFAULTS,用防御式 readConfig(ctx.config) 取值。若将来要暴露可配置项,请引入 @deepseek-ai/schemastery 并导出 Schema.object({...})。

补充:这一条是在热重载时暴露的(运行中的宿主缓存了旧模块对象,ESM 按 URL 缓存、改文件不清缓存)。所以改完插件代码要整应用重启再判断,不要依赖 HMR 的即时结果。

崩溃 3:webServer.register 的路由对象缺 kind → 路由静默不生效

症状:插件 fiberPhase: active(加载成功),但 /design-ledger/* 一律 404(对照:dsh-whale 的路由返回 401,因为它在信任栅栏之后)。

机制:WebRoute 的真实契约是

interface WebRoute {
  kind: 'exact' | 'prefix'   // ← 必需
  path: string
  handler: (req, res) => void | Promise<void>
}

没有 `method` 字段。我曾写成 { method: 'GET', path, handler } → register() 抛错 → 被我自己包在注册外面的 try/catch 吞掉,于是路由根本没注册,而插件看起来一切正常。

注册方式的对照(重要):可跑通的 dsh-whale-widget 是这样做的 ——

root.inject(['webServer', 'credentials', 'connection'], (ctx) => {
  ctx.webServer.register({ kind: 'exact', path, handler })   // 直接注册,不套 ctx.effect
})

即:用注入作用域上的 `ctx.webServer` 直接注册,不要包 `ctx.effect`。本插件最初写成 ctx.effect(() => webServer.register(...), label),且解析顺序写成了 ws?.webServer ?? ctx.get('webServer') ?? ctx.webServer;现已改为与前者一致(先 ctx.webServer、直接注册)。

教训:防御式 try/catch 会把契约错误变成静默失败。所以:

  • probe/route-test.mjs 加了契约自检(断言每条路由 kind 合法),缺 kind 时以非零码退出;
  • 注册的三种结局都会打一条 [design-ledger] 日志:panel routes registered: 3 (…) / ROUTE REGISTRATION FAILED: / webServer unavailable; panel routes NOT registered。宿主不把 console.warn 落盘,所以排障时要看应用终端/控制台。

崩溃 4:面板配色自创 token → 黑底看不清 / hover 白底白字

症状(两次,同一个根因):

  1. 弹窗"太黑、看不清上面的字"(浅色主题下);
  2. 鼠标移到「选择设计文档」「载入为设计文档」上时,背景变白、字也是白的,字直接消失。

机制:颜色 token 必须照抄官方组件,不能凭名字猜。两次都栽在这上面:

我写的 · 真实情况 --dsw-alias-bg-1 / --dsw-alias-text-1 · DSH 里不存在 → 永远走 fallback 的硬编码深色 --dsw-alias-bg-layer-2 当 hover 底色 · 存在,但浅色主题下就是纯白(实测 var(--dsw-static-neutral-bluish-00) = #fff)

第二个还叠加了一个 CSS 层叠问题:.dlg-btn:hover 与 .dlg-primary 特异性相同 (0,2,0), 主按钮的底色/文字又是硬编码的(brand 底 + color:#fff);hover 规则一覆盖底色, 就成了"白色背景 + 白色文字",字彻底看不见。

正确来源:不要去猜,直接读官方组件的 CSS —— @deepseek-ai/dsh-client-ui-primitives 的 Button.module.css 就是权威:

.primary            { background: var(--dsw-alias-button-primary-fill);   color: var(--dsw-alias-label-primary-foreground); }
.primary:hover      { background: var(--dsw-alias-button-primary-hover); }
.ghost:hover        { background: var(--dsw-alias-interactive-bg-hover); }
.outline            { border: 0.5px solid var(--dsw-alias-border-l3); }
.outline:hover      { background: var(--dsw-alias-interactive-bg-hover); }

关键取值(从主题 CSS 里读出来的真实定义):

token · 浅色 · 深色 · 用途 --dsw-alias-interactive-bg-hover · #2631480f · #ffffff14 · 交互底色首选(带透明度,两种主题都安全) --dsw-alias-button-primary-fill · var(--dsw-alias-brand-primary) · 同 · 主按钮底色 --dsw-alias-button-primary-hover · var(--dsw-static-neutral-bluish-750) · …-100 · 主按钮 hover(有独立规则) --dsw-alias-label-primary-foreground · #fff · #0f1115 · 主按钮文字 --dsw-alias-bg-layer-2 · `#fff` · #2b2b2b · 抬升面 —— 不要拿它当 hover 底色

做法:交互底色一律用 --dsw-alias-interactive-bg-hover;主按钮用 button-primary 三件套; hover 一律写 :hover:not(:disabled)(避免禁用态变色、也顺手规避层叠打架)。 npm run check:client 会断言这些约定,防止再犯。

`Theme.listTokens` 只列出 14 个 token,是子集,不含上面用到的 button/interactive 系列 ——
所以"用什么 token"要以官方组件 CSS 为准,别只看 inspect 列表。

崩溃 5(最严重):systemPrompt 段落文本返回非字符串 → 整个对话一按发送就失败

本轮运行失败
text.indexOf is not a function          UNKNOWN

症状:只要插件处于启用状态,任何消息都发不出去:一发就红字「本轮运行失败」,整个会话瘫痪。 这比面板 404 严重得多 —— 面板坏了还能用对话,这一条把对话本身打掉了。

机制(@deepseek-ai/dsh-system-prompt 源码):宿主在每一轮装配时对每个已注册段落做

// assemble()
text: typeof section.text === 'function' ? section.text(context) : section.text
// 之后 renderPrompt(assembly) → interpolate()
for (let open = text.indexOf('{{'); …)   // ← 第一行就是这里

即 PromptSection.text 的契约是 string | ((context) => string),必须同步返回字符串。 一旦返回 Promise / object / undefined,interpolate() 立刻抛 TypeError: text.indexOf is not a function,而这是回合开始处的调用 —— 于是整个对话不可用。

修复做法(lib/index.js 注入段):

  1. text() 内只读内存缓存,绝不读盘、绝不 await、绝不返回 Promise;
  2. 注入文本由后台任务(refreshInjectionCache)预先算好;apply() 时预热一次,

台账变动(design_ledger_init / design_ledger_update / bind 路由)后 invalidateInjection() 重算;

  1. 过期判断只做一次 statSync(index.json).mtimeMs 比较,不用每次读文件;
  2. 出口再兜一层 typeof !== 'string' → '',并写 INJECT-NONSTRING 诊断日志。

纪律:这一段永远不要写成 async text()。渲染失败最多让注入内容缺失,绝不允许影响对话。

崩溃 6:defineTool 的 parameters 传了"预编译好的根 schema" → 六个工具全部静默失效

症状:插件 fiberPhase: active,注入正常,面板正常,但 design_ledger_* / design_doc_* 一个都不存在(模型调用会得到"未知工具")。而插件自己的日志里只有一行 warn,很容易被当成"没问题"。

机制:官方 defineTool(options) 的 options.parameters 要的是隐式属性映射:

// ✅ 正确:属性表 + 属性上的 required 标注;根对象由宿主编译生成
parameters: { rel: { type: 'string', description: '…', required: true } }

// ❌ 错误:手工预编译根 schema
parameters: { type: 'object', properties: { rel: {...} } }
// → defineTool 构造期抛:
//   unsupported JSON schema: parameters.type must be a value schema object
//   (parameterSchemaSpecToJsonSchema 把它当成一个"值 schema"节点,
//     而 "object" 值节点要求显式 additionalProperties,于是报 type 不合法)

因为本插件用 try/catch 把构造期异常吞成了"跳过这一个工具",结果是六个全跳过、 插件却看起来一切正常。防御式 try/catch 又一次把契约错误变成了静默失败。

纪律:lib/index.js 的 defineTool() 适配器只做两件事 —— 把顶层 required: [] 摊到属性上、 给缺 type 的标量补 string;根 schema 绝不自己拼。另外:对象属性必须显式 additionalProperties: true,且不要给 type: 'array' 补 items(会让参数校验去匹配元素形状)。

崩溃 7:面板把部署默认根当成了工作区 → 弹窗空白,且「工作区根」切不回去

症状:侧栏「设计文档包含」能打开,点「选择设计文档」弹窗显示 「(此目录下没有子目录或 .md 文件)」;面包屑点「工作区根」也回不到工作区 —— 因为那个根已经被当成工作区锁死了。

机制:宿主路由没有 session 上下文,sandboxPolicy.resolve() 给的是部署默认根 (本机实测 D:\AppData.dsh\profiles\desktop),不是用户的工作区。 「工作区」这个事实只存在于会话侧:工具调用的 exec.agent.session、agent 事件。 面板拿不到它,宿主路由也猜不到,于是列了 profile 目录。

修复(四层,缺一不可):

  1. 会话工作区旁路记录(rememberSessionWorkspace):任何一次带 session 的解析

(工具调用、agent/created、agent/pre-step)都把工作区记下来,无 session 的路由缺省就用它。 这是关键 —— 它让旧版面板也能落在正确的工作区上。

  1. 主动发现(discoverSessionWorkspace):插件可能在会话诞生之后才加载