# 设计说明 本文件记录 dsh-spec-forge 的判据、算法与成本账。README 只讲"有什么功能",这里讲"为什么这样设计、怎么算的"。 --- ## 一、为什么用 `nextStep` 而不是隐含约定 早期版本靠"分级器判了 L1,所以你该跳过体检"这种**隐含约定**来省步骤。实测失败:分级器明明返回 `fastTrack=true`,模型却因为技能文档里写着「第 2 步:体检(**必做**)」的措辞,照样把六步走完——一条一轮能做完的小需求白跑 3 次工具往返。 **分级判得再准,也拦不住一个写着"必做"的流程。** 所以改成**机器可读的显式指令**:`spec_recall` 直接返回 `nextStep`,常驻提示段与 Skill 文档都写「严格按 `nextStep` 执行」。同一件事只在一个地方计算、只在一个地方表述。 `nextStep` 现有三态(0.8.0 起 `triage` 只留给 L3): | 值 | 含义 | 工具调用数 | | --- | --- | --- | | `implement` | 直接实现,禁止追问(含 L1 fastTrack 与 L2 默认值直通) | 1 | | `confirm` | 先问清 `contentGap`(或安全阀)再实现 | 1 + 1 次提问 | | `triage` | 仅 L3:先调 `spec_triage`,完整先问后查 | 4 | 为什么收回 L2 的 `triage`:0.8.0 用 44 条真实需求回放,41 条落 `triage`(其中 40 条零信号的 `L2:default`), 而报告终点写的是「直接按默认动手、不追问」——与 `implement` 等价,等于每次多一轮往返 + 1.3K 字符。 L2 真正需要的默认值改为随召回内联(`renderInlineDefaults`),`triage` 回到它原本的语义:大范围改写。 --- ## 二、分级判据 ### 三级响应 | 等级 | 什么算这类 | 插件怎么做 | | --- | --- | --- | | **L1 原子操作 / 快速通道** | 单文件 CRUD 且字段/组件/默认值明确;带参考物的自包含新建;**取值型原子小改**(改文案/调样式/改按钮样式/字段改名/改默认值);或消息含"直接做/速做/不用问/别问/不要问/极速模式" | 不许追问,直接给执行清单 + 扫目标文件风格自举,疑虑标 `// TODO: [待确认]`;原子小改默认连沉淀都跳过 | | **L2 模块变更** | 模块级新增/调整,一句话推不全 | **按报告默认值直接执行,不追问**;仅"过短 + ≥2 维缺失 + 零锚点"或"缺内容"转一次性追问 | | **L3 架构重构** | 含架构/重构/拆分/迁移/升级/建表/跨文件/多模块 | 完整 Grill-me,问透为止 | ### 原子小改的两类闸门 判"能不能一轮直达"要看**两个独立的问题**,早期版本只看了第一个: **闸门组 A:范围(改动多大)**——三道,宁可漏判也不误判: 1. 需求长度 ≤ 48 字 2. 不含多任务连接词(以及 / 同时 / 顺便 / 还有 / 另外 / 并且 / 然后 / 一并 / 一起) 3. 不含大范围限定词(整体 / 全局 / 所有 / 全部 / 批量 / 每个 / 各个 / 多处 / 整个 / 全站 / 全量) 所以「加个按钮,同时把列表也重构一下」「统一所有按钮的文案」会被正确挡住,留在 L2。 **闸门组 B:内容可决性(有没有说清要做的对象是什么)**——按族区分: | 族 | 例子 | 缺的是什么 | 结果 | | --- | --- | --- | --- | | 取值型(copy / style / constant / rename / tiny / button-attr) | 改文案、调间距、改按钮样式、超时改成 30s | 取值(可逆、可见) | 放行 | | 容器型(button / column / route) | 加个按钮、加个路由、加一列、加个菜单项 | 新增物**是什么、干什么** | 无内容则 `confirm` | **判据一句话:缺「新增物的身份/用途」必问,缺「已有物的属性取值」自决。** 容器型的内容信号有三种,命中任一即认为已给出"是什么": 1. 容器名词前的修饰语 —— 「加个**忘记密码**按钮」 2. 用途动词紧邻容器 —— 「加个**导出**按钮」 3. 点击行为 / 引号文本 —— 「加个按钮,**点击跳转注册页**」 ### 两个刻意的设计决定 **① 目标文件路径不算内容信号。** 需求常带文件路径前缀(`@…/some-page/index.vue 登录页加个按钮`), 那是"改哪个文件",不是"按钮做什么"。若把它当内容,真实那条需求会被误放行。 **② 内容闸门不受长度闸门约束。** 长度闸门是"长文本≈多目标"的粗代理,而带长路径前缀的单点需求会被它误伤 (实测那条恰好 48 字,路径再长一个字符就整个漏掉)。所以内容检查只排除多任务/大范围信号,不看长度。 ### `confirm` 为什么独立于 `unactionable` L2 原有的追问安全阀是: ``` unactionable = tooShort && missing.length >= 2 && !hasAnchor ``` 它判断的是"整条需求还没成形"。但"缺内容"是另一回事——整条需求很清楚,只缺新增物是什么。 实测数据说明两者不能互相替代: | 需求 | tooShort | missing | hasAnchor | 若只降到 L2,安全阀会问吗 | | --- | --- | --- | --- | --- | | `登录页加个按钮` | true | 3 | false | 会 | | `@…/some-page/index.vue 登录页加个按钮` | **false** | 2 | **true**(识别出文件) | **不会** | 带路径前缀 → 变长 + 拿到 file 锚点 → 安全阀根本不触发。**所以"缺内容"必须是独立于长度与锚点的理由。** ### 为什么不直接走 `spec_triage` `confirm` 缺的只是一个信息,付一次完整四维体检不划算。所以它跳过第 2/3/4 步, 直接用一次 `ask_user_question` 问清 `contentGap` 里列出的内容就动手。 ### 提问权的封闭列举 只有两种情况允许 `ask_user_question`:`nextStep=confirm`(含 `contentGap` 与 L2 安全阀)、L3。 0.8.0 起安全阀本身就走 `confirm`——它的动作一直是"问一次再动手",此前却挂在 `triage` 上, 变成"先体检、报告再叫你去问",多一轮往返。 提问前禁用任何文件类工具(read/grep/glob/bash/ls),一次问完。 这道约束在常驻提示段、Skill 文档、工具描述三处同时写着。 ### L1 保守默认表 不明确时直接套用,宁可少做不瞎猜: | 维度 | 默认 | | --- | --- | | 列表展示 | 不展示(如需展示请主动告知) | | 业务校验 | 不加,仅做基础必填/非空校验 | | 后端支持 | 默认已支持(仅前端改动) | --- ## 三、召回算法 `spec_recall` 收到需求后:把原文拆成加权指纹 → 与模板库逐份比对打分 → 超阈值就注入(默认最多 2 份),连同项目禁区一起。 ### 打分公式 ``` 总分 = 0.62×词汇相似度 + 0.14×同分类 + 0.08×标签重叠 + 0.08×同仓库 + 0.05×新鲜度 + 0.03×使用热度 词汇相似度 = 0.6×加权余弦 + 0.4×覆盖率 ``` ### 六个维度 - **词汇相似度是主项。** 指纹按 token 加权:路径(4) > 技术词(3) > 标识符(2) > 中文 2-gram(1), "改 `index.vue`"比"有个页面"值钱得多。 **通用基名降权**:`index.vue` / `main.js` / `app.vue` / `package.json` 这类任何项目里都有一堆的基名 不再吃满路径权重——否则 `…/some-page/index.vue` 与 `…/another-page/index.vue` 会共享一个 权重 4 的 `index.vue`,看起来像命中了同一条路径。降权只降权重、保留 token,避免误伤"改 index.vue"类真实需求。 - **查询侧先聚焦再比。** 用户需求常常很长(带路径、叙述、寒暄),直接取 24 个 token 会有一多半是权重 1 的 2-gram, 稀释余弦与覆盖率。查询指纹先削掉低信号尾巴(强 token 全保留 + 最多 8 个 2-gram)再进打分—— 同一条真实需求,聚焦前后词汇分差约 0.03~0.1,跨仓库(无同仓库加分)时这点余量就是命中与否的分界线。 **落盘模板的指纹保持完整不动。** - **覆盖率剔除路径。** 路径与文件名(`…/detail.wxml`)回答的是"改哪个文件",不是"要做什么"。 实测一条 112 字的需求带 5 个路径 token(权重合计 17),中文语义词只剩 8 个:分母被路径占满, 命中一个语义词覆盖率也只有 0.04。所以覆盖率只统计语义 token,余弦仍用全量(路径对区分同类页面有价值)。 - **已知缺陷(待修)**:`focusFingerprint` 的 2-gram 截断取的是排序后的前 N 个,而排序键含 token 字母序 —— 中文 2-gram 的字母序就是拼音顺序,没有语义意义。真实回放里「不允许操作」这条需求被截断后, 查询指纹与模板的交集只剩"操作/当前/实际","时段"根本没进指纹。修法是按**出现顺序**截断, 已在 WorkBuddy 壳层验证有效(该条 0.023 → 0.144),待随指纹改版一起回灌。 - **同分类按一级比。** 模板的二级分类(如 `feature/api` 里的 `api`)由模型沉淀时自由填写、不可控, 所以 `feature/api` 与 `feature/pagination` 视为同类。查询侧的分类和标签是插件从需求原文现推断的,不依赖模型自觉。 - **标签重叠做了别名归一。** 模板里存 `a-switch`、需求里写"开关",两边要能对上——组件别名 (`a-switch`→`switch`、`element-plus`→`elementplus`)、中英技术词("分页"→`pagination`)归一到同一把钥匙; 重叠率按"模板标签被查询覆盖的比例"算。 - **同仓库 + 新鲜度 + 热度是调节项。** 本仓库沉淀过的模板优先;90 天半衰期衰减;命中越多越可信但用对数压平,避免马太效应。 - **同仓库 + 新鲜度 + 热度只用于排序,不参与命中。** 0.7.0 起:`hit = lexical ≥ threshold`, `rankScore = 0.9·lexical + 0.1·(同分类 + 标签重叠 + 同仓库 + 新鲜度 + 热度)`。 旧版把先验项无条件加进总分(合计最多 0.38),阈值 0.35 意味着词汇只需贡献 0.06~0.09 —— 实测无关需求能拿 0.333(差 0.017 越线),而真命中常因 lexical 只有 0.15~0.25 被挡在门外; global 层没有"同仓库"可加,命中率实测只有 1/6。把先验移出判定后,同一批 16 例基准从 3/12 变 12/12。 ### 阈值标定 阈值默认 **0.15**(**词汇分**,不是含先验的总分),`matchThreshold` 可调:调低更易命中(会引入误召回),调高更严格。 16 例真实形态基准(覆盖长短句 + 无关样本)的分离度: | 需求 | 词汇分 | 结果 | | --- | --- | --- | | 同类短句:跨天时长少算一天 | 0.49 | 命中 | | 同类短句:提示不可操作但按钮还能点 | 0.37 | 命中 | | 同类长句:插件装上但注入一次都没出现 | 0.48 | 命中 | | 无关:接口加一个分页参数 | 0.098 | 不命中 | | 无关:调整首页间距字号 | 0.00 | 不命中 | > 标定经验(两次都踩过): > ① 旧版曾打算"给词汇分量设门槛",量分布后发现真命中的 lexical 0.142 与假阳性 0.129 几乎重合 —— > 但那是**在含先验的总分体系里**测的,先验把两者的差距抹平了。把先验移出判定后,窗口变成 0.098~0.19。 > ② 不要在"总分"上加减系数来救召回率,先确认判据是否用对了量。 > **改阈值类逻辑前请先重跑两两分布**,并且样本必须包含**短句**(真实用户往往只写 7~10 个字, > 长句基准会给出过于乐观的结论)。 ### 性能 模板列表在进程内带读缓存(写盘版本号 + 文件名集合双重失效),模板库到几百份时也不会每次召回都全量读盘解析。 --- ## 三·五、输入清洗与指纹生成(0.7.1) ### 为什么先清洗 真实会话回放(24 条需求)里 38% 把接口 JSON 或错误堆栈整段粘进来。不清洗时,这些内容撑大向量模长, 任务描述被稀释:EBUSY、跨天时长这类本该命中的需求词汇分只有 0.02~0.10,全部漏召回;清洗后回到 0.23~0.32。 `lib/prepare.js` 做四件事:剥代码块/堆栈/内部路径/长 hex、按**括号配对**剥内联 JSON、 回填字段名(≤8 个、排除通用键)、最后截断。 三条容易做错的: 1. **先清洗完整原文,再截断**。反过来 JSON 被切成半截(只有 `{` 没有 `}`),剥离规则失效。 2. **JSON 不能整段丢**。字段名是"改哪个字段"的唯一线索;但全收 24 个会让命中从 7 掉到 5,8 个刚好。 3. **剥离阈值不能定太高**。曾用 30 字符,恰好把 30 字的真实块漏掉;改 16。 ### 指纹怎么生成 | 侧 | 函数 | 规则 | | --- | --- | --- | | 查询 | `queryFingerprint()` | 强 token 全保留 + 2-gram **按出现顺序**取前 16 个 | | 模板 | `fingerprintTemplate()` | 分级配额:引号里的用户说法 → 名称 → 标签 → trigger 其余 → 正文,总长 40 | 两条实测结论: - **不能按字母序截断**。旧路径先按 `(权重, token 字母序)` 排序再截断,而中文 2-gram 的字母序 就是拼音序,没有语义意义 —— 「不允许操作」那条需求的指纹里只剩「操作/当前/实际」。 - **不能"索引词全收"**。试过把 name+trigger+tags 全部收进指纹(64 个 token),向量变长后 其它模板的 cosine 全线下滑(某条真命中的分数从 0.157 掉到 0.148)。 另有一处基础修复:`STOP_CHARS` 含「不」「可」「用」,逐字过滤会把「不可 / 可用 / 允许 / 无法」 整组丢掉 —— 这是门禁类需求长期不命中的真因。现在 `PROTECTED_GRAMS` 白名单让它们按技术词权重保留。 > 0.7.1 改了生成方式,**存量模板必须重建指纹**:`npm run rebuild`(先 `--dry-run` 看变化)。 ### 指纹上限 40 是怎么定的(0.7.2) 上限不是拍脑袋:名额越大召回越广,但余弦分母也越大。用 `npm run bench:cap` 跑三组指标, 0.7.2 实测(7 份真实模板): | 上限 | 自我召回 | 平均分 | 正文词召回 | 无关需求最高分 | 误命中 | | --- | --- | --- | --- | --- | --- | | 24 | 7/7 | 0.565 | 3/7 | 0.256 | 0 | | 32 | 7/7 | 0.641 | 5/7 | 0.233 | 0 | | **40** | 7/7 | 0.611 | **7/7** | 0.209 | **0** | "自我召回"用 trigger 做查询(检验索引词覆盖),"正文词召回"用标准改法做查询(检验正文词覆盖)。 只看前者会得出"32 最好"的错误结论 —— 平均分 40 比 32 低 0.03,但正文词召回从 5/7 升到 7/7, 而前者本来就有 0.6 的余量、掉 0.03 无感。**改这个常量前必须两组都看。** ### 主题证据:不是所有重合都算数(0.7.0 引入,0.7.2 补容器词) 命中要过两道门:`lexical >= threshold` **且** 至少一个"主题词"重合。第二道门挡的是 "词面沾边但主题无关"—— 0.7.2 实测 10 条无人值守的无关需求里,修前 5 条误命中,唯一交集 全是容器名词: | 无关需求 | 唯一交集 | 覆盖模板数 | | --- | --- | --- | | 列表默认按创建时间倒序 | `列表` | 4/7 | | 表单校验提示文案改一下 | `表单` | 3/7 | | 详情页样式要统一 | `详情` / `情页` | 3/7 | | 卡片上显示个图标 | `卡片` / `显示` | 1/7 | 判据不是"这个词语义上重不重要",而是**它有没有区分度**。`列表` 覆盖 4/7 份模板, 拿它当证据等于没做。所以低信号分两类:动作词(提交/新增/接口)与结构词(列表/表单/详情页)。 中文 2-gram 会切碎片(`情页` ⊂ `详情页`),因此判定同时看"token 是结构词的片段"; 但不看反方向 —— `订单列表`、`组件库` 仍带主题信息。 `breakdown` 会输出 `hasTopicEvidence` 与 `topicEvidence[]`,**排查"为什么没命中"先看这两个字段**: `score` 过线而 `hasTopicEvidence=false` 就是被这道门挡住的,不是算法失效。 --- ## 四、四个工具 | 工具 | 什么时候被调用 | 干什么 | | --- | --- | --- | | `spec_recall` | 收到编程需求的**第一件事** | 翻模板库,把命中模板的澄清清单/标准改法/验收标准 + 项目禁区注入上下文,并给出 `level`、`fastTrack`、`contentGap` 与 `nextStep`。判定 `implement` 时会省略「澄清清单」并滤掉改法里引用 `spec_*` 的流程行(那两块与"禁止追问"冲突,且占返回量七成) | | `spec_triage` | `nextStep=triage` 时 | 四维体检 + 定 L1/L2/L3。L1 出执行清单,L2 出「已按默认执行」,L3 出完整 Grill-me。不重复扫模板库,也不接收 `cwd` | | `spec_distill` | L3 与安全阀场景澄清完毕、动手之前 | 把需求 + 澄清答案 + 禁区蒸馏成一份实现提示词;禁区为空会拦下 | | `spec_retro` | 任务收尾 | 把这次经验沉淀/更新成模板;digest 留空时自动从会话事件流提取摘要 | 0.3.3 的 `spec_store` 曾被并入 `spec_library`;**0.6.0 把 `spec_library` 整个删掉了**(工具数 5 → 4)。 依据:15 个真实会话里它被调用 **0 次**,却占 481 token/请求;而它承担的"看模板库 / 跨路径搬数据 / 清过期模板"三件事,根子上都是**文件系统自带能力** —— 模板库就是一堆带 frontmatter 的 markdown, 看一眼、拷一份、删一个文件都不需要插件代码。 --- ## 五、注入分四层 | 层次 | 机制 | 内容 | 成本 | | --- | --- | --- | --- | | 常驻 | 系统提示词 section | 由 `lib/render.js` 的 `ROUTING_CONTRACT` 渲染:三态路由 + 提问白名单 + 大文件纪律 + 沉淀门槛 | 约 489 token,始终占用(0.4.7 起内容与代码同源) | | **每轮** | `agent/pre-step` 注入(0.5.0) | 按本轮需求原文现算的 `nextStep` 硬指令,只命中 L1 一步直达 / 需求缺内容两态;`triage` 与普通对话不注入 | 命中 136(L1)/ 150(缺内容)token,不命中 0(`npm run token-audit` 实测) | | 按需 | 运行时 Skill | 完整流程说明书(含三层漏斗决策与大文件纪律) | 用到才加载 | | 执行 | 四个工具 | 上一节的表 | 调用才产生 | > **为什么要有"每轮"这一层**:常驻段与工具返回值都是"模型先读到、再自觉执行"的软约束。 > 真实会话里 20 次召回有 17 次紧接着调了 `spec_triage`(召回已经判过的结论,模型又走一遍流程); > 0.4.6 记录的事故则是模型自己替用户挑了按钮用途。pre-step 让插件**自己算、直接送**, > 不再经过"模型是否照做"这一环。判据与 `spec_recall` 同源(同一个 `classifyComplexity`、同一份原文), > 所以两处结论不可能自相矛盾——这是它敢下硬指令的前提。 > > 实现遵循内置插件的既有范式:`dsh-tool-skill` 用同一个事件注入 skill 目录, > `dsh-repeat-tool-reminder` 用同一个事件手搓 `createUserMessage`(本插件同样自己构造消息, > **不新增任何依赖**)。异常一律原样放行,注入失败只影响这一轮的提示,不影响本轮执行。 > 常驻段有一个自设参考线 `SYS_BUDGET = 450` token,**它不是 dsh 的硬限制**—— > dsh 的 `renderPrompt()` 只做 `sections` 排序 + `join('\n\n')`,没有任何截断或长度上限,超线只是多花 token。 > 曾经为压到线内做了一轮"为压缩而压缩",结果静默删掉了 4 条约束(其中「报错排查」只存在于该段,删了就真没了)。 > **请勿为了压线删约束**;`scripts/token-audit.js` 的判定已写成动态文案,超线只提示"按内容需要取舍"。 --- ## 六、成本归因 一次真实会话被逐事件解码后的账: | 项 | 量级 | 说明 | | --- | --- | --- | | 插件固定税 | ≈ 2.7K token / 请求 | 常驻提示段 ≈ 489 + 四个工具定义 ≈ 2237(0.6.0 实测) | | 插件单次调用产出 | 几百 token | `spec_recall` 未命中仅 43 token;`spec_triage` 报告 350~530 token | | 插件占整轮成本 | ≈ 4% | 8 次 `spec_*` 调用,返回 14.5K 字符,占全部工具返回的 3.2% | | **真正的成本大头** | **≈ 50%** | 两个 >100K 字符的文件被**整读**后,在其后约 73 步里被反复重计 | **结论:插件不是成本黑洞,整读大文件才是。** 长会话里约 98% 的 token 是 `cacheRead`(上下文每步重发), 所以最有价值的省钱手段是"别把大文件塞进上下文",而不是砍插件。 ### 往返成本 0.4.3 之前简单需求也要走满 3 次工具往返。治理后按真实模板库回放 7 条样本: 合计调用 22 → 14 次(−36%),简单需求全部 3 → 1 次(−67%)。省的不只是 token,还有每一跳的固定税。 ### 配套开关 不在插件里,在 dsh / 模型侧: - 长会话开启上下文压缩(compaction),避免每步重发全量上下文 - 预算敏感时降低 `reasoningEffort`——一次实测里 `high` 档的推理 token 占输出的 56% ## 七、模板卫生(0.8.1) 模板一旦落盘,就会在**未来的不相关任务**上被注入 —— 模型没有能力判断一条约束是否已经过期。 所以「写进去」比「事后清理」危险得多,判据必须放在写入侧。 三条硬规则: | 规则 | 判据 | 落地位置 | | --- | --- | --- | | 禁区不得写一次性任务边界 | **范围词 + 具体文件**同时出现(「本次只动 lib/store.js,不碰 index.js」) | `lib/hygiene.js` 的 `isTaskScopedRedline`;`seed` / `spec_retro` 写入前丢弃;`doctor` 报问题 | | 项目级禁区归项目档案,不回写模板 | 特征词(接口/API/后端/前端/mock/校验/调用逻辑…);**不按出现次数** | `classifyDuplicateRedline`;`scripts/rebuild-project-scope.mjs --hoist`;`doctor` 只报项目级重复 | | 项目模板指纹只用 name+tags | `fingerprintTemplate({ topicOnly: true })` | `seed --scope project`;存量用 `scripts/rebuild-project-scope.mjs` 重建 | | 极短/仅引用的输入不召回 | 有效字符 <6,或 <15 且带截图 | `lib/prepare.js` 的 `shouldSkipMatching` | 「项目级 vs 主题级」为什么不按重复次数判:实测「不擅自改跨天快捷时长语义」在 3 份模板里重复, 但那 3 份同属"跨天时间"主题族 —— 次数只说明同一主题被拆成了几份模板,不能说明它跨主题成立。 按次数搬进档案,等于给全仓库任务注入一条无关约束,是污染的反向。 为什么第一条最要紧:它的失败方向是**把该做的改动锁死**。实测某模板的「不碰 index.js」, 在下一次技能审计请求上以 lex 0.48 命中并被当硬约束注入,而那次任务恰好需要改 index.js。 通用纪律(「只改用户指定的页面文件」「临时脚本用完即删」)不点名任何文件,在任何任务里都成立,应当保留。