# dsh-study-buddy 经验文档(两轮重构实录) > 版本基线:v1.1.1 | 读者:插件/预设的维护者 > > 本文件收录**两轮**过程方法论与踩坑: > - **第一轮(v0.5.0 → v0.9.1)**:卡片框架重设计 + 两轮审查修复(§一~§四) > - **第二轮(v0.9.1 → v1.0.0)**:文档式笔记重构(§五~§八,见下方「第二轮」大节) > - **补记(v1.0.0 → v1.0.2)**:归档链路 P0 修复(§九、§十) > - **补记(v1.0.2 → v1.1.0)**:DSH 0.1.7 删除目录式预设 → 迁成声明式 bundle(§十一) > - **补记(v1.1.0 → v1.1.1)**:v4 消息来源准入拦下开场门禁,整轮失败(§十二) > 更早几轮见 `docs/archive/dev-experience/`(已归档)。只谈过程与踩坑,不重复设计/技术口径。 --- ## 一、做对的三件事 ### 1. 先读真实数据,再定正则(本轮最大的一笔省时) 提案给了一版会话残留正则集。**在写代码前,先对真实 vault 只读抽样了 223 张卡**,结果与提案假设差距很大: | 提案假设 | 实测 | | :-- | :-- | | `L\d+` 即行号残留 | 15 张命中里绝大多数是 `L0/L1/L2`(球谐带)与 `GAMES101 L15`(讲义引用) | | `今天/昨天` 是会话残留 | 大量用于口语化定义("明天天气只由今天决定") | | `M1/M2/M3` 是阶段代号 | 真实语料里多为里程碑与数学记号,照抄会误报一片 | | 自测题常缺答案 | 223 张卡 100% 带 `→` 答案(该问题早已修) | 于是规则从"宽正则 + 事后解释"改成"**窄判定 + 白名单**",并把白名单写成**反例单测**。 成本约 3 次只读命令,收益是**零返工**。 > 通用化:凡是"用规则判断自然语言产物"的需求,**规则精度只能靠真实语料校准**,不能靠推理。 ### 2. 地基先行,让三个阶段共用一套模型 原实现把"卡片该长什么样"写成 `src/card.ts` 里一个常量 + 字符串 `includes` 校验,正文是整块字符串。 若直接在其上加 lint,P1 的模板分型与 P2 的跨卡一致性都会被逼着再做一次字符串手术。 做法:先建 7 个纯函数模块(cardmodel / template / lintrules / lint / history / rename / insight), 再抽出 `tools.ts`,`index.ts` 只留 `VaultStore` + `apply` + re-export。 **判据是"后续加东西只改一个文件"**(见 `docs/设计文档.md` §6 扩展点表)。 阶段 0 的验收方式是**行为零变化**:既有 136 项测试断言一行不改,全绿。这让"大重构"变成低风险动作。 ### 3. 兼容口径显式化(存量数据不返工) 真实 vault 里 `### 阶梯式解剖` 有两种写法:155 张不带后缀、69 张带"(第 1 层 → 第 4 层)"。 若按规范标题精确匹配,这 69 张会被误判缺节。 于是定义:**小节标题匹配 = 精确相等,或以规范标题开头且紧跟括号/冒号/破折号**。 一条规则同时兼容 `核心思想(直击)`、`阶梯式解剖(限制清单…)` 等 5 种历史变体,并写进单测钉住。 > 通用化:**存量数据的存在本身就是一种兼容性要求**——凡是新增校验,先想"旧数据会被判成什么"。 --- ## 二、踩过的坑(第一轮,按浪费量排序) ### 1. 成对结构用正则全局匹配 → 闭栅栏被当成开栅栏 `codeFenceLanguages` 初版:`/(?:```|~~~)[\s]*(\S*)/gm` 全局匹配。 于是 ```` ```hlsl x ``` ```` 被解析成 `['hlsl', '', '', '']`——开栅栏、闭栅栏、下一个开、下一个闭全被计入。 - **根因**:fenced block 是**成对结构**,无状态全局匹配无法区分开/闭。 - **修复**:逐行扫描 + `inFence` 翻转;单测直接断言 `['hlsl', '']`。 - **通用化**:凡是"开/闭成对"的语法(栅栏、`
`、HTML 标签、括号),一律用状态机逐行扫,不用正则全局匹配。 ### 2. 领域标签判定用"含 `-`"当子域 → `Cook-Torrance` 被误判 真实语料里补充标签大量含连字符(`Cook-Torrance`、`Blinn-Phong`)。 初版把"含 `-`"当子域键,结果正常标签被判成"根域/子域混挂"。 - **修复**:子域 = 含 `-` **且** 有另一个标签是它的前缀(或与已知子域键同前缀);只报"根域 + 子域同时存在"。 - **通用化**:符号约定(分隔符)不能单独作为语义判据,必须加上"与已知集合的关系"。 ### 3. 改名后的断链检测把"新标题包含旧标题"当成残留 `A` → `A 与探针` 时,`line.includes(oldTitle)` 恒为真,改完立刻报"仍指向旧标题"。 - **修复**:断链判定先排除"已含新标题"的行。 - **通用化**:**字符串包含判断在"改名/替换"场景必须考虑新旧值互为子串**。 ### 4. 断言没跟上格式变更 → 一次 5 处红 `addLink` 归一为 `` `标题`(ID) `` 后,`card.spec` / `store.spec` 里 5 处断言仍按旧格式写。 处理:逐处改成新格式(**改断言而不是回退实现**)。 - **可复用做法**:改格式前先 `grep` 全仓库的格式断言点(`- 后续:`、`- 前置:`),一次性改完再跑全量。 - **配套教训**(沿用上一轮):断言用带引号的精确子串,别用宽泛词。 ### 5. PowerShell `Set-Content` 把 LF 改成 CRLF → 违反 `.gitattributes` 用 `Set-Content` 删/改文件后,`src/index.ts` 变成 731 行 CRLF,而仓库 `* text=auto eol=lf`。 git 提示 "CRLF will be replaced by LF the next time Git touches it"。 - **修复**:`[System.IO.File]::ReadAllText` → `.Replace("`r`n","`n")` → `WriteAllText`(UTF8 无 BOM), 再逐文件统计 CRLF/LF 确认全仓库一致。 - **通用化**:**用 shell 工具改文件后必须检查行尾**;批量检查用字节扫描(统计 `0x0A` 前一字节是否 `0x0D`)。 ### 6. 文档移动后的"断链"不是文本断链,而是引用断链 把 12 份文档移入 `docs/archive/` 后,`docs/用户使用指南.md` 与 `README.md` 里 5 处路径引用失效 (复盘文档、体检报告、需求稿、`docs/dev-experience/` 目录)。 移动前先 `grep` 出**全部**引用点(含 markdown 链接与反引号路径),移动后逐条 `Test-Path` 验证。 - **通用化**:**移动文件前先建立"引用清单"**;移动后做两件事——grep 残留、逐路径存在性校验。 ### 7. 计数类文案是"全局易漏项"(多轮 grep) `134 项` / `201 项` / `9 个工具` / `≥~500 字` / `五小节` 分散在 README、用户指南、技能、persona 里。 本轮统一处理:**阶段末一次性 grep 全集 → 分文件替换 → 残留 grep 兜底**,历史快照文档手工豁免。 - **通用化**:沿用前几轮结论——规则/计数类文案先 grep 全集再改,别边写边改。 --- ## 三、可复用的技巧(第一轮) ### T1. 权重表集中化:打分规则只在一个对象里 14 条 lint 规则的权重原本写在 `WEIGHTS` 一个常量对象里,规则实现、报告渲染、文档口径三处引用同一份; 改口径(如"会话残留从 10 分降到 8 分")只改一行,测试自动跟随。 审查轮进一步把「权重 + 中文名 + 级别 + 适用范围 + 判定函数」合并成一张 `RULES` 注册表: **加一条规则 = 加一个数组元素**,`ruleIds()`、工具描述、`rule` 枚举全部派生——集中化的收益会随规则数放大。 ### T2. 反例单测比正例更重要 lint 的精度问题无法靠推理解决。本轮为每条白名单都写了反例: `L0/L1/L2`、`L2 正则化`、`LOD`、`GAMES101 L15`、`第 15 讲`、代码块内、`
` 内、引用块内第二人称。 **反例定义了"什么不该报"**,它才是精度的护栏。 ### T3. 批量工具的输出必须是"聚合视图" `card_lint({scope:'vault'})` 若逐卡打印,223 张卡会淹掉上下文。 设计为:均分 + 分数分布 + 规则命中排序 + 最低分 N 张(默认 20,可调);明细要显式要(`rule:` 过滤)。 **任何"全库/全量"类工具,默认输出都该是聚合视图。** ### T4. `dryRun` 是写入类工具的标配 `card_rename`、`card_history strip` 都提供 `dryRun`:返回将改动的文件清单/将删除的块,不写盘。 对"会碰用户数据"的工具,这一步几乎零成本,却能避免绝大多数误操作。 ### T5. 文档也要有"归档判据" 本轮为文档归档定了三条判据(已完成 / 已取代 / 仅供溯源),并写进 `docs/archive/README.md`。 有了判据,"该不该归档"就不再是每次重新讨论的问题。 ### T6. 先只读、再动手:数据类需求的通用流程 ``` 只读抽样 → 定规则与白名单 → 写单测(含反例) → 再实现 → 端到端验证 ``` 本轮所有涉及用户数据的动作(规则校准、lint 基线、改名验收)都在**临时目录或只读模式**下完成, 真实 vault 全程零写入。 ### T7. 修复类需求的流程:先复现,再写用例,最后改实现 审查报告给了 40+ 个问题,最省时的顺序不是"按严重度从上到下改",而是: ``` 把每条结论变成一条会失败的断言(探针转正) → 跑红 → 改实现 → 跑绿 ``` 好处:① 不会"以为修好了";② 审查期临时删除的探针脚本变成永久护栏;③ 改完能立刻知道有没有连带破坏。 本轮 40 个修复点落成 39 项新增测试(209 → 248),其中 3 个 blocker 各自有专门的回归用例; 复审轮再补 15 项(248 → 263:13 条 N 系列回归 + 2 条文档卫生守卫),其中 4 项专盯"修复副作用"(见 T11)。 ### T8. 行号是"最脆的坐标",跨重排就会漂移 `stripHistory` 的缺陷(删掉整个「关联卡片」小节)根源是:**先重排文本,再拿原始行号去删**。 通用规则:**只要中间发生过任何"重排/删除/插入",之前的行号就全部作废**。 两种安全写法:① 让删除与行号计算在同一坐标系(先删后者);② 干脆不用行号,改走段落模型按标题/配对定位。 本项目最终选了"先删 `
`(同坐标系)→ 再按段落模型过滤小节",并给 `dropDetailsBlocks` 加了越界防御。 ### T9. 绿灯不等于覆盖:测试用例的位置决定了它能否发现问题 `stripHistory` 原有的"清除全部历史块"用例恰好把 `
` 放在被删的勘误小节内部, 于是删除勘误时 details 顺带消失,行号漂移永远命中不到目标——**测试通过,缺陷潜伏**。 教训:为"破坏性操作"写用例时,要刻意构造**最不利的输入**(details 在被删小节之外、小节间有多余空行), 而不是最"顺手"的输入。 ### T10. 文档一致性可以用测试钉住 `domainFolders`(YAML)、`card-format` 技能里的键名表、`template.ts` 的工程族三处曾经靠人肉同步。 现在 `tests/skills.spec.ts` 直接解析 YAML 与 Markdown 表并断言集合相等;persona 的"整段重复"也用 "同一行出现两次以上"的规则自动查;**规则清单**(`ruleCatalog()` 的中文名)同样断言在 SKILL.md 与用户指南里各出现一次。 **能被解析的东西,就别靠自觉。** ### T11. 修复的副作用比原缺陷更难发现(复审轮 v0.9.1) 第二次审查的结论值得单独记一条:**三个 blocker 都真修好了,但修复本身引入了 3 个新问题**—— `card_link` 加了 `assertWritable` 却没调整顺序(把"误写"换成"半写 + 报错")、`card_create` 为一条同名提示 加了 `ensureIndex`(热写路径上多付一次全库扫描)、索引 TTL 的早退把"外部编辑即时可见"变成了 2 秒窗口。 三条可复用纪律: 1. **加校验时要问"它在写入之前还是之后"**——校验位置比校验本身更决定数据一致性; 2. **在热路径上加任何"顺手读一下"的调用,先量一下它的代价**(一条提示不值得一次全库 `stat`); 3. **缓存/加速类改动必须同时定义"失效路径"**——本次的答案是:缓存只作用于命中路径,未命中一律强制重扫一次。 4. 复审建议的四条回归(只读根 `link`、`create` 不重建索引、TTL 窗口内外建卡可见、无尾换行不粘行) 全部落成永久用例——**副作用类缺陷只有用例能防住,靠人看 diff 看不出来**。 --- ## 四、第一轮一句话总结 **本轮零逻辑返工**,浪费集中在三处:两处正则的语义误用(成对结构、子串包含)与一处工具副作用(行尾被改)。 最值钱的三条纪律: 1. **规则类需求先只读校准再写码**——真实语料的分布和直觉差得很远; 2. **成对结构用状态机**——正则全局匹配会吃掉配对信息; 3. **新旧值互为子串时先排除新值**——改名/替换场景的经典陷阱。 **审查修复轮(v0.9.0)追加一条**:**修 bug 的第一件事是把结论变成会失败的断言**—— 审查报告里的每条"实测"都必须在测试里复现一次,否则修完仍是"看起来修好了"。 **复审轮(v0.9.1)再追加一条**:**修完要按"这次改动可能破坏什么"再验一遍**—— 本轮 15 条修复里有 13 条是新加的用例,其中 4 条(N1~N4)专门盯"修复副作用"; 只验证"原缺陷消失"的验收方式,会系统性漏掉修复引入的回归(见 T11)。 --- # 第二轮:文档式笔记重构(v0.9.1 → v1.0.0) > 范围:需求分析(26 问)→ 架构选型 → 重构计划 → 8 个阶段执行的全过程。 > 结果:工具 12 → **18**(`card_*` 整族退场)、模块 16 → **20**、测试 268 → **251**(删掉的是被删功能的用例)、 > 每请求 schema 开销 **14.7 KB**(旧 15.0 KB)。需求/选型/计划见 `docs/refactor/需求分析.md`、`docs/refactor/架构选型.md`、`docs/refactor/重构计划.md`。 ## 五、本轮做对的三件事 ### 1. 先把"要什么"问成可验收的条款,再动代码 需求只有三句抱怨(内容简陋 / 笔记堆叠 / 约束过多)。直接用它们开工,会在两个地方翻车: "简陋"改到什么程度算够?"解除约束"之后用什么替代? 做法是**分 5 轮问 26 个问题**,每题都给候选与取舍,把抱怨落成 30 条决策(`docs/refactor/需求分析.md` §三), 其中 6 条当场标"待定稿"留给架构阶段。收益在阶段 4 兑现:`note_write` 的门禁契约、 `来源章节` 的语法、目录层级、`domainFolders` 的降级——**全部是问答里定下来的,没有一处返工**。 > 通用化:**模糊需求先转成"可验收的决策表"**,尤其是"取消 X"这类否定式需求——必须同时定下"用什么替代"。 ### 2. 门禁落在工具侧,而不是写进 persona 需求方明确要求"未读期望 / 未确认规划不写"。如果只写进 persona,那就是**提示词级别的承诺**:模型可能忘。 若只放在进程内存,DSH 重启就凭空失效。 最终形态:状态落 `.study/`(签名 + 规划凭据),判定放 `gate.ts`,由 `note_write` 强制。 端到端用例直接断言**目标文件不存在**——门禁的价值不在"报错文案好看",而在"磁盘零改动"。 > 通用化:**凡是"必须先做 A 才能做 B"的约束,问一句"它落在哪一层"**。落在提示词层的约束是建议,不是约束。 ### 3. 删旧与建新同批(中间态不能是黑洞) 阶段 3 要删三型模板与字数硬限,但工具面(阶段 4)还没切换。若直接删 `card.ts`, 中间态会**既不能建卡也不能建块**。 做法:先落地 `note.ts`(新契约),再让 `card.ts` 变成**受控兼容层**(模板入参被忽略、定义映射为简介、 但必填与换行校验仍 fail-loud),工具面切完后再整片删除。 全程 `pnpm run check` 绿——**每一批结束都是一个可用状态**。 > 通用化:大重构的批次边界,应当切在"**任何时刻仓库都能跑**"的位置上,而不是切在"概念最清楚"的位置上。 ## 六、本轮踩过的坑 ### 1. `passed` 的语义变了,规则过滤跟着错(最隐蔽的一个) `card_lint rule=X` 原来靠 `report.passed[X] === true` 判断"跑过且通过", 用 `undefined` 表示"未启用/不适用"。v1.0 的报告里 `passed` 只收录**零发现**的规则—— **有发现的规则不在 `passed` 里**,于是"未通过"被误报成"未启用"。 - **修复**:判定"跑过没有"必须同时看 `passed` 与 `findings`(`ran = passed[X] === true || hits.length > 0`)。 - **通用化**:**改数据结构的语义时,先 grep 出所有"用字段值反推状态"的调用点**。 "键不存在 = 未执行"这种隐式约定,在字段语义变化时不会报错,只会静默给出错答案。 ### 2. 批量删代码用行号区间 → 越删越乱 阶段 6 要删 6 个旧方法。第一版按"手数出来的行号区间"删,一次删穿方法边界(丢掉收尾 `}`), 文件直接语法错误;接着连续三轮用行号区间补救,每轮都因为**上一次删除已经让行号漂移**而错得更远。 - **修复**:放弃行号区间。改为**先 `git checkout` 回到干净态,再用"签名行 → 大括号配平"定位方法体**, 或按"签名行到下一个 ` }`"取整块。 - **通用化**:**删除类操作不要用绝对行号**,用结构定位(签名/配平/哨兵)。 这与 v0.9 的 T8("行号是最脆的坐标")是同一个坑,只是换到了"编辑源码"这个场景。 ### 3. `lib/types` 不清 → 删掉的模块仍可被 import `tsc` 增量输出到 `lib/types/`,删掉 `card.ts` 后 `lib/types/card.d.ts` **仍然存在**: 消费方(或自己)还能 `import type { CardInput } from 'dsh-study-buddy/...'`,编译期零信号。 - **修复**:`build.mjs` 在 `tsc` 之前 `rmSync('lib/types', { recursive: true, force: true })`。 - **通用化**:**构建产物目录与源码目录一样需要"删除同步"**;声明文件尤其危险,因为它们不参与打包。 ### 4. `PowerShell Set-Content` 顺手改了编码与行尾(v0.9 坑 5 的复发) 用 `Set-Content` 做小改动时,文件被写成 **UTF-8 BOM + CRLF**,仓库是 `* text=auto eol=lf`。 后续用 `[IO.File]::ReadAllLines` 读到的行与编辑器里的行对不上,进一步放大了坑 2 的行号漂移。 - **修复**:批量改动一律走 Python(`io.open(..., newline='\n')` 读写)或 `[IO.File]::WriteAllText`(UTF-8 无 BOM)。 - **通用化**:这条**第二次**踩到了——**把它当成硬规则**:在 Windows 上做文本改写的默认工具不是 `Set-Content`。 ### 5. 索引"不再常驻正文"牵动了三处隐藏依赖 `IndexedCard.body` 删掉后,除了检索本身,还有三处依赖它: 批量体检(原来直接用索引里的正文)、改名预筛(原来按"正文含待改字面量"跳过读盘)、 `snippet` 计算(原来对所有命中算)。 - **修复**:批量体检改逐篇按需读盘(低频操作,可接受);snippet 只在命中前 N 篇现读; **改名预筛直接去掉**——改名是低频操作,"正确性优先于省几次读盘",且只有真改动才进写入计划。 - **通用化**:**删一个"缓存字段"之前,先 grep 它的全部读者**,并逐个判断"读盘重算"的代价能否接受。 这次能顺利过,靠的正是 grep 出来的三处,没有第四处。 ### 6. 目录索引按"自身文件"判断子树 → 缺微目录被误报 `note_list` 判断某个目录"有没有微目录"时用了该目录**自身**的统计, 但父级目录(如 `第2章 …/`)通常只放章节子目录、文件都在下一层,于是它永远显示"⚠ 缺微目录"。 - **修复**:计数与"有无微目录"一律**按子树累加**(`DirIndex.countAt` / `hasTocIn`)。 - **通用化**:树形结构的展示指标,先想清楚"这是自身属性还是子树属性"——两者在 UI 上长得一样,语义完全不同。 ### 7. 裸 `localeCompare` 把宿主环境写进了排序结果(本地绿、CI 红) v1.0.0 推到 main 后 CI 红在 `tests/overview.spec.ts` 的一条断言上:期望 `计算方法` 在前,实得 `算法设计与分析` 在前。 本机是全绿的——因为**比较结果跟着宿主 locale 走**:本机 zh-CN 按拼音(计 ji < 算 suan), GitHub runner 的 en-US 走根排序(按部首)。同一份 vault 在两台机器上会列出不同顺序,平时没人会注意。 - **修复**:新建 `src/order.ts`,把 14 处裸 `localeCompare`(`dirs`/`overview`/`lint`/`archive`/`vault`/`search`/`memory`) 收成一个显式 pin 的 `compareText`(`zh-Hans-CN` + 数字按数值),排序器返回 0 时回落码位比较保证全序。 - **为什么不是"改断言就完事"**:断言只是症状。顺序是**用户可见结果**(`note_list` 子目录、覆盖度分组、存档列表), 两端各自都能"自洽",但用户看到的是随机。 - **守卫**:① 源码扫描——`src/` 里除 `order.ts` 外不许出现裸 `localeCompare` / `Intl.Collator`; ② 把 `String.prototype.localeCompare` 临时换成 en-US 实现(`try/finally` 还原),断言聚合与目录顺序不变 ——**在 zh 机器上重演 CI 环境**,不用等 push。 - **通用化**:任何"看起来只是顺序"的比较,都要问一句"结果由内容决定,还是由运行环境决定"。 默认 locale、默认时区、默认换行符是同一类东西:**默认值就是环境**。 ## 七、本轮可复用的技巧 ### T12. 硬门禁的验收标准是"磁盘零改动",不是"报了错" `tests/noteflow.spec.ts` 里每条门禁用例都成对断言:**报错文案含关键短语** + **目标文件不存在**。 只断言报错会把"写了一半才报错"当成通过。这与 v0.9 的 D13(数据安全优先)是同一条纪律的延伸。 ### T13. "先存档、后写正文"要收成一个函数,而不是散在调用点 `archiveThenWrite(vaultRoot, {...旧内容, write: () => 写新正文})` —— 顺序被封在函数里, 任何新增的替换路径只要走它就不会写出"正文已改、历史已丢"。 测试用"把存档目录位置占成文件"让存档必然失败,断言**目标正文一个字都没动**。 > 通用化:**不变量要收成唯一入口**;靠"调用者记得按顺序调用"的不变量,迟早会被新调用点破坏。 ### T14. 验证脚本要跟着"这版改了什么"重写 v0.9 的验证脚本检查的是"分型回显、总分、历史折叠块"。v1.0 把这些都删了, 脚本若不改就会**全部报 ✘**,而人只会得出"这版坏了"的结论。 重写后的脚本把重点放在**门禁真的会挡住写入**(三条门禁各自验证"被拒 + 文件不存在")与"改期望立即生效": 50 项里第 12~17 项是门禁专项。 > 通用化:**发布前问一句"验证脚本还检查得动这版吗"**;功能删除后,旧脚本是最容易被忘掉的假红源。 ### T15. 计划文档要记"偏离",不只是记"完成" 执行中必然有取舍(本轮 5 处:头读 vs 全读、state/memory 是否合并、工具数取上限…)。 只记"已完成"会让后来者以为计划被完整执行;记下偏离与理由,才是可交接的文档。 `docs/refactor/重构计划.md` §九 的偏离表每行三列:**计划原文 / 实况 / 判定与理由**。 ### T16. 用"临时替换全局函数"在一个环境里复现另一个环境的失败 CI 的失败条件往往是环境差异(locale、时区、行尾、大小写敏感),这类差异可以在单测里**造出来**: v1.0.0 的排序故障就是把 `String.prototype.localeCompare` 临时替换成 en-US 实现复现的—— zh 机器上一次就能跑出与 GitHub runner 相同的结果,且不依赖 runner 装没装那个 locale。 要点三条:① 替换必须 `try/finally` 还原(漏了会污染同文件后续用例); ② 断言要打在**受影响的真实入口**上(覆盖度聚合、目录列举),不是只测比较器本身; ③ 同一个故障再配一条**源码扫描守卫**(口径不许散着写),一条防"环境",一条防"下次又写回去"。 ## 八、第二轮一句话总结 本轮的浪费集中在两处:**用行号区间做删除**(连锁返工四轮)与**`passed` 的隐式语义**(静默错答)。 最值钱的五条纪律: 1. **否定式需求必须同时定下替代方案**——"解除约束"不是终点,"约束从哪来"才是; 2. **约束落在哪一层决定它是不是约束**——提示词层是建议,工具层才是门禁; 3. **删除类操作不用绝对行号**,用结构定位;这是同一个坑的第二次记录; 4. **删缓存字段前先 grep 全部读者**,逐个判断"重算代价"能否接受; 5. **排序/文本比较必须显式指定口径**——默认值就是运行环境,顺序也是用户可见结果(v1.0.0 CI 的最后一课)。 --- # 补记:归档链路 P0 修复(v1.0.0 → v1.0.2) 起因是 [`check/验证报告-2026-09-15-学习伙伴预设实战检查.md`](check/验证报告-2026-09-15-学习伙伴预设实战检查.md) 在真机会话里 逐条跑完 50 项后给出的一条 P0:**`note_plan` 没有确认入口**,`note_write` 在任何会话都必然被"规划尚未确认"拦下。 修复与复验见 [`check/验证报告-2026-09-15-归档链路修复复验.md`](check/验证报告-2026-09-15-归档链路修复复验.md)。这一轮的坑比上一轮更值钱,因为它不是"实现写错了",而是**验证体系本身有洞**。 ## 九、这一轮暴露的四件事 ### 1. "测试全绿"与"用户点不出来"可以同时成立——只要测试绕过了那一步 两道门禁校验都要求 `confirmed`,而全仓库没有任何置位点。测试之所以没抓到,是因为它们**自己动手补了那一刀**: ```ts // tests/noteflow.spec.ts(修复前) const record = await readPlan(file) record!.confirmed = true // ← 绕过被测行为本身 await writePlan(file, record!) ``` 于是测试覆盖的是"确认之后的链路",而"怎么确认"从未被执行过一次。**纪律:测试夹具不许替被测代码做它该做的事**—— 凡是"用户要做一步、工具才能继续"的流程,夹具必须调真实入口(本轮改成 `notePlan({ action: 'confirm' })`), 否则绿灯只证明"绕过之后能用"。这条与 §七 的"断言要打在受影响的真实入口上"是同一枚硬币的两面:一个管**断言打在哪**,一个管**前置条件从哪来**。 ### 2. 报错文案指向的入口,必须真的存在(且被测试钉住) 报错写着 `请让用户拍板后用 note_plan(action=confirm) 确认`——但 `confirm` 从来没被实现。**一句正确的建议 + 一个不存在的能力 = 死锁**, 而且死锁还自带"看起来可解"的假象:模型会照着文案调用、失败、再换措辞试,用户看到的是"AI 反复说要确认却确认不了"。 现在拒绝文案**就本次 planId 给出可直接执行的调用式**(`note_plan({ action: "confirm", rootPath: "" })`), 并由 `tests/gate.spec.ts` / `tests/noteflow.spec.ts` 用正则把它钉住——**文案里的动作名一旦改坏,测试先红**。 ### 3. 门禁的两个方向都要有出口:能挡,也要能开 设计文档里写着"门禁必须可解(会过期、可 abandon)",但漏了更基本的一条:**必须可开**。 "未确认不写"只有与"确认入口存在"成对出现时才是门禁;单独存在就是一把焊死的锁。 同类检查值得当成一条通用验收项:**每一条拒绝,都要有一条被测试覆盖的通过路径**。 ### 4. 修 P0 时顺手撞出的第二个死锁:`padStart(2)` 补不满 1 位数字 `String(9).padStart(2, '0')` 的结果是 `'09'`(长度 2)——看起来没问题,但时间戳要的是**固定 12 位**, 而 `pad()` 只保证"至少不短于 2 位"的直觉是错的:真正的坑在于**依赖拼接结果长度的**校验式。 实测:23:06 生成的 planId 是 12 位(正常),而 09:07(以及 00:00~09:59 任意时刻)生成的是 **11 位**, 传回 `planFileFor` 时被自己的正则 `^[0-9]{12}_…$` 拒绝——**凭据刚发出去就读不回来**。 纪律两条:① **凡是"生成 + 校验"成对的标识符,加一条"刚生成的必须能通过校验"的不变量测试**(本轮加在 `tests/gate.spec.ts`, 并用 `new Date(2026, 0, 5, 9, 7)`、`00:00`、`23:59` 三个边界钉住);② 时间戳类字符串统一走一个 `pad2()`,不在各处手写 `padStart`。 ## 十、方法上的一处补充 这一轮的定位成本几乎全花在**"为什么写成 40 小时前的记录却报 24 小时过期"**上——最后发现是**测试自己算错了参照物** (把 40 小时当成"未过期",而 TTL 是 24 小时),而 `isPlanExpired` 一直是正确的。 当时的排查动作是有效的、值得记下来: 1. 在分支里打一行诊断(`planId / 路径 / record.createdAt / now`)——**先看真实值,再改代码**,不要在推测里改实现; 2. 诊断要打在**被测分支内部**,不要只打在测试侧(测试侧的变量可能早已被你自己的写盘覆盖); 3. 结论出来后**立刻删掉诊断**(本轮的 `[WRITEPLAN-DIAG]` / `[CONFIRM-DIAG]` 都只活了几分钟), 并在同一轮里用**断言**替换它——诊断是临时的,断言才是资产。 ## 十一、补记:目录式预设被删(v1.0.2 → v1.1.0) ### 1. 最贵的失效形态:不是报错,是"行消失" DSH 升到 0.1.7-rc.1 后,用户看到的是"「学习伙伴」没了、18 个工具全没了",而现场证据是: - 插件源码零改动,`pnpm run check` 全绿; - 平台契约探针 13 条全绿(`tools.register` / `systemPrompt.section` / `agent/pre-step` / `session.snapshotEvents` / `header.cwd` 都在); - `plugin_manager list_plugins` 里**没有** `study` 行;`Config.listConfigs{name:dsh-study-buddy}` 是空目录。 **这组证据的正确读法是"没挂载",不是"挂载失败"**:行不存在 = 声明不存在,与插件代码无关。 先分清这一步,才不至于去改一堆无辜的代码(本轮的第一价值就来自这个判断)。 ### 2. 根因取证:从"平台现在读什么"入手,不要从"我的代码哪里错"倒推 有效的三步(都不用通读宿主体源码): 1. **在平台源码树里 grep 资产路径**:`grep -rn '\.agent-presets' T:\deepseek-harness\packages` → **零命中**。当年能跑、如今没人读——这就是根因; 2. **读官方技能**:`cordis-composition-reference` 的迁移一节原文 *"Nothing reads that directory any more"*, 连迁移步骤都写好了("create a bundle … install it … then delete the legacy directory"); 3. **核对提交**:`d1e22a7e24 / #4569 feat(preset): declare Agent compositions in profile YAML`。 ### 3. 迁移手册写着"已核查无需处理"时,要复核它的核查范围 上一轮排查(settings 去命名空间)把本仓判为"无需处理"——**没错**,因为本仓零 `ctx.settings` 调用; 但同一份手册把"本次变更只有这一处"写成了结论,于是这次没人往"预设挂载形态"上看。 纪律:手册给的是**上次的**根因与流程;"本次只此一处"永远当假设,用本仓的活证据复核一遍。 ### 4. 交付形态也是契约;探针要断言"平台还能读到它" 契约探针只断言 API 形状(`ToolRuntime.prototype.register` 还是不是函数)**挡不住这一类故障**: 文件放错地方、平台换了装配方式——API 一切正常,而插件根本没被装配。 所以 `tools/verify-contract.mjs` 现在有两类断言:**交付形态(文件级)** + **平台契约(活符号)**, 外加负向断言("假 ctx 不给 `settings`(0.1.7 已删 `register`)也必须注册 18 个工具")。 负向对照 6 例全跑,任何断言被改坏都必须报红(迁移手册铁律 3)。 ### 5. 包与部署分层:机器相关的东西一行都不进包 包一旦开源/多机共用,"作者本机绝对路径"就是发布内容。这次顺手把 `vaultRoot` 赶出包,做成四级来源链 (行 config > 环境变量 > 用户级 JSON > 默认),并把**来源**打进挂载日志——副产品是排查变快: "到底读的哪个 vault"不必再翻配置文件。另:`vaultRoot` 是挂载期不变量,改完要重启,这条限制要写进文档。 ### 6. 顺手清掉会误导下一轮的东西 `%DSH_HOME%\.agent-presets\study\` 留着还能被人工打开,看起来"预设就在这儿"——它已经没有任何读者了。 留着只会让下一次排查从错误方向开始,所以部署校验脚本把"legacy 目录必须已删除"列为一项 FAIL (`pnpm run verify:deploy -- -Profile web`)。 --- ## 十二、补记:v4 消息来源准入(v1.1.0 → v1.1.1) ### 1. 同一个字段的"语义升级",失效形态是**整轮失败** 上一轮(§十一)是"行消失、不报错";这一轮正好相反:新会话第一条消息就甩一行 `本轮运行失败 format v4 message requires a producer-owned source kind`。 平台 0.1.7 的 v4 会话格式把「消息来源」从**包装字段**(`{kind:'plugin', plugin:'x'}`) 升级成**生产者自有的 kind**(`kind:'x'` 或第三方插件的 `plugin:x`),而且这条检查挂在 **写盘编码器**(`codec.encodeEvent`)上——不是解析、不是校验器: - 影响面:`agent/pre-step` 的注入消息一落盘就被拒 **⇒ 整轮失败**,连用户消息都不落盘; - 语义面:平台注释写得很直白 *"each producer declares its own `kind`; there is no shared catch-all `plugin` kind"*;迁移表还给出了第三方插件的规范名(`plugin:` + 原名)。 教训:**"来源/身份"这类字段升级时,判断影响面要看它挂在哪条路径上**。挂在写盘路径上 = 整轮失败; 挂在读盘路径上 = 会话打不开;挂在投影上 = 只是渲染怪。三者要的测试与紧急度完全不同。 ### 2. 归档文档里的"已核查仍合法"会过期,必须标注失效指向 `docs/archive/dev-experience/dev-experience-2026-09-platform-adaptation.md` 里有一行醒目的 `✅ 仍为合法 UserMessage 源(compaction-basic 同款)`——那是 0.1.3 时代的核查结论,依据是 "别的第一方插件也这么写"。这轮它被证伪了。处理方式:**只加失效标注 + 指向新记录,不改写历史结论** (归档文档的价值就在于保留当时的判断依据)。 教训:跨版本的"仍合法"结论要写清**依据的平台版本**;平台升级后先怀疑这类行,而不是先怀疑自己的实现。 ### 3. 探针要断言"活平台真的收下了这条消息",不要只断言自己的形状 这轮修复顺手把探针升级成两层: - **常跑**(不依赖平台包,任何机器/CI 都有增量):假 ctx 跑 `apply` + `agent/pre-step`, 断言**真的会被注入的那个对象**——kind 非空、≠ `plugin`、等于 `plugin:<包名>`、无 `plugin` 字段; - **活断言**(解析到平台包时):把这个对象包成 `user/message` 行喂给平台自己的 `releasedV4SessionFormatCodec.encodeEvent`(**就是线上写盘那条函数**),必须通过; 同时要求退场的包装**必须被拒**——否则报"断言已失去意义"。 两条都必要:常跑那条保证任何环境都不退化;活断言那条保证"平台的**现在**这套语义真的收下它"。 `--self-test` 也补了第 7 例(把注入来源改回包装必须报红),沿用"校验脚本一旦失去牙齿就毫无价值"的纪律。 ### 4. 复现要用"同一条代码路径",不要用"看起来等价"的最小例子 最省时的取证动作是:**把产物真的注入的提醒对象,喂给本机运行中的平台函数**。 它一次给出两件事——复现原文错误 + 证明换掉 kind 就能通过(还能顺手验证平台自己给出的 迁移映射值,从而保证新旧行身份一致)。相比之下,"照着报错信息手写一个样例"很容易换错路径 (比如只测校验函数、不测编码器),得出"我这边没问题"。 ### 5. 平台源码树的包,可能只在一个 `node_modules` 布局里可解析 本机 DSH 从源码树(`T:\deepseek-harness`)跑,profile 的 `node_modules` 里**没有** `@deepseek-ai/*`, 平台包只在 pnpm 的 `.pnpm/node_modules` 里可解析。契约探针原来只找 profile 目录,于是"活符号" 那一段悄悄跳过。现在 `locatePlatformModules()` 补了 pnpm hoist 候选,并给探针加了 `--platform `,让"活断言能在本机真的跑起来"。