# research-wiki 是什么,各个目录收什么 这里是本项目除代码之外的绝大部分文档。这一份说明它怎么组织、你要写的东西该放哪。 两个例外留在仓库根目录,因为它们要在克隆仓库的第一眼就被看到:`README.md`(项目概览、消费者与 当前进度)和 `CLAUDE.md`(协作约定)。除这两份之外,新写的文档都进 `research-wiki/`。 先说结论,赶时间的话看完这张表就够: | 目录 | 收什么 | 会不会被改写 | |---|---|---| | `explanation/` | 为什么这样设计 | 会 | | `migrations/` | 每个下游项目迁走什么、迁完算不算数 | 会 | | `reference/` | 查得到的事实:日志字段、遥测口径 | 会 | | `guides/` | 怎么做某件事:发布、排障、本地环境 | 会 | | `design/` | 动工前的方案与权衡,写完冻结 | **不会** | | `scratch/` | 一次性草稿,由人在每轮工作会话结束前清理 | 不适用 | `reference/`(仓库根目录那个,不是本目录下的)不在表里:那是参考资料,不是本项目的文档, 理由见 `../CLAUDE.md` §0。 下面解释这个划分是怎么来的,以及为什么值得遵守。 --- ## 1. 两层,判据是「发现它不对了,你会改它还是留着它」 前五个目录分成两层,界线是这一句: > **如果这份文档和事实对不上了,你会去把它改对,还是原样留着?** 会去改对的是**常青层**(`explanation/`、`migrations/`、`reference/`、`guides/`)。它的职责是描述 当前的真实情况,一旦不符就是在说谎,必须修。「常青」(evergreen)是文档领域的常用说法, 指一份需要长期保持有效的文档,与之相对的是写完就归档的一次性文档。 原样留着的是**记录层**(`design/`)。它记录的是某个时刻我们知道什么、决定了什么。就算后来 证明当初判断错了也不改——因为它的价值正在于保存「当初的判断是什么」。写完就冻结。 **为什么判据是这一句,而不是「描述现在还是描述过去」。** 后者听起来更直观,但切不动一类 很常见的文档:一个外部工具的固有行为(比如某个命令的输出会被缓冲),它既是现在的事实 也是当时的事实,按时间根本分不开。而用上面这句一问就清楚了——如果那个工具升级后行为变了, 你当然会去把文档改对,所以它属于常青层。 同理,一次设计决策做完的当天,它的理由既是「当时」也是「现在」,时间判据同样失效。但如果 三个月后这个决策被推翻,你不会回去改那份 design doc,而是新写一份——所以它属于记录层。 ### 为什么必须分开 因为两层的维护规则是互相排斥的,混在一起没法同时成立: - 常青层的规则是「改代码时必须同步改它」。 - 记录层的规则是「写完就冻结」。一份三个月后被人改过的决策记录,已经没法回答 「我们当初为什么这么定」了——它变成了「我们现在觉得当初应该这么定」,而这两件事在 排查历史问题时差别很大。 同一个目录挂不上这两条规则。不分开的实际后果,CHSAnalyzer 的上一版演示过:因为没人区分 哪份该更新、哪份不该更新,结果是全都不更新。那一版的 `API.md` 落后代码 210 个提交, `CLAUDE.md` 落后 676 个提交,而两份文档当时都写着要保持同步。 ## 2. 常青层:四类,各自要有更新触发点 前三类的划分借自 Google 的工程文档实践,以及 Diátaxis——一个把文档按「读者此刻想干什么」 分成教程 / 操作指南 / 参考 / 说明四类的文档组织框架,读作「迪亚塔克西斯」。两者对这几类的 切法基本一致。第四类 `migrations/` 是本项目自己加的,理由在它自己那一节。 本项目**故意没有「教程」那一类**。教程是带零基础的人走完一遍完整流程的入门材料,它的成本 很高(每次流程变动都要重走一遍验证),而本项目目前的协作者都已经在项目里了,没有真正的 新手入门场景。等真的需要时再建 `tutorial/`。 划分的意义在于**一份文档不要同时追求两个目标**——参考手册里插一段设计动机,查参数的人会 被打断;设计说明里塞满字段表格,想理解全局的人会被淹没。 ### `explanation/` —— 为什么是这样 回答「能讲讲 X 吗」。分层为什么这么切、某个约束为什么存在、某个坑背后的机制是什么。 这类文档允许有观点,也应该写清楚考虑过哪些替代方案。 本项目最重要的一份常青文档 `explanation/architecture.md` 会在这里,它说明分层、依赖方向和 抽象接缝。它的触发点是第 1 档(由 `pyproject.toml` 的 import-linter 契约断言,文档和代码 对不上 `make lint` 直接失败)。 **这份文档会先于代码存在。** 第 ③ 阶段的正题就是把架构理清楚,而理清楚的产物只能是文档; 如果要求文档必须落后于代码,那这个阶段就没有产物,只能并进实现阶段,变成「边写边想」。 代价是那段时间它没有机器兜底——契约要等 `src/` 落地才写得出来。对策有两条:文档开头放一段 醒目的状态说明,写清楚它描述的是目标而不是现状;实现时**在同一个提交里**把守着这块代码的 那条契约加上,不是同一个 PR,是同一个提交,因为提交是能被单独回退的最小单位。 ### `migrations/` —— 每个下游迁什么、迁完算不算数 一个下游项目一份。内容是删除清单(它那边哪些代码由本库继任)、组件映射(旧的哪个类对应 新的哪个接缝)、以及验收口径(迁完之后跑什么算通过)。 **这一类单独设,而不是塞进 `guides/`**,因为它的读者和用途都不一样。`guides/` 的读者手上 有一件确定的活要干,看完就照做;`migrations/` 的读者在判断「本库现在够不够用」——它同时是 本库的验收标准和边界证据。本项目的核心验收标准就是能不能搬回 dissect、能不能替代掉 GovDoc-SaaS 的 `docagent-core/`,把这件事藏在操作指南里会让它看起来像可选的工序。 触发点是第 2 档:改公共 API 时同一个提交里改它。它不设长度上限——删除清单本来就该写全, 砍长度只会让它变得不可信。 **真正的验收不是这份文档,是把下游迁过来跑它的测试。** 文档只是清单和审计记录;一份写着 「已完成迁移」而没有人真跑过下游测试的迁移文档,说明的只是我们相信自己做完了。 ### `reference/` —— 查得到的事实 回答「X 的取值是什么」。日志字段契约、遥测口径。特点是读者已经知道自己要找什么,只是来 核对,所以它要准确、完整、好检索,不需要循循善诱。 **有一类看起来该收在这里、但故意没有收的事实**:公共类型的字段、不变量与枚举取值。它们的 权威是代码本身,不另写一份文档复述——那份文档不重复代码的内容太少,而它腐烂的速度和代码 一样快。哪类事实的权威在哪,`../CLAUDE.md` §0 的表格是总索引。 ### `guides/` —— 怎么做某件事 回答「我要做 X,步骤是什么」。发布流程、本地环境搭建、排障。读者手上有活要干,所以只给 能达成目的的路径,不展开讲原理——原理放 `explanation/`,需要时链过去。 发布流程是这一类里最重要的一份,因为它的每一步都是欠账换来的:PolyGateway 有两个版本 完成了版本号 bump 与 CHANGELOG 却从未上传,registry 长期停在旧版,下游 `pip install` 拿不到 任何修复且无人发现。 ### 每一类都必须写明更新触发点 **「更新触发点」指的是:什么事情发生时,这份文档一定会被改。** 这是常青文档不腐烂的唯一 可靠机制。没有触发点的常青文档,靠的就是「大家记得更新」,而这件事在 CHSAnalyzer 上一版 已经失败过一次。 触发点的强度分三档,能用强的就不用弱的: 1. **机器断言**(最强)。文档说的和代码不符,CI 直接失败。例如架构文档由 import-linter 契约 断言分层,日志字段契约由结构化日志的测试断言。 2. **同 PR 同改**(次强)。改某块代码时,改文档是同一个提交的一部分。这是 Google 的做法, 好处是不依赖任何人事后记得。 3. **定期复审**(最弱)。**每三个月**看一眼,并在文档头部更新「最后复审」日期。周期必须写死, 否则读者拿到那个日期也算不出文档过没过期,这一档就等于没有。三个月这个值取自 Google 的 做法,本身没有特别的道理,重点是它是个确定的数。只在前两档都做不到时才用这一档。 外部工具的固有行为(例如某个命令的缓冲方式)也属于常青层,它的触发点通常是第 2 档: 我们依赖它的那条规则改了、或者那个工具升级后行为变了,就在同一个提交里改这份文档。 **写一份新的常青文档时,要在文档开头写明它属于哪一档、触发点具体是什么。** 写在开头而不是 集中在一张索引表里,是因为索引表本身也会腐烂——它会漏掉新加的文档,而写在文档自己头上的 东西,改这份文档的人一定会看到。 如果三档都想不出来,那说明这份文档不该写成常青的——要么它其实是记录层的内容,要么它不该存在。 ### 第一节必须从一个看得见的东西起步,词表不许挡在正文前面 这条是 CHSAnalyzer 踩出来的,它量了自己五份 `explanation/` 文档,规律很干净:好读的三份都 从一个具体的东西开场(一个临床问题、一张处理线的图、一个「命令跑完了没有输出」的现象), 难读的两份都从一个抽象的定位开场(「队列入口那层代码非常薄」、整节在讲「我和另一份文档的 分界线在哪」)。**而且和长度无关**:五百多行那份没人说难,六百行那份读不动。差别不在长短, 在第一页给了读者什么。 所以定两条: **一、第一节必须从一个读者已经能看见的东西起步**——一个真实的问题、一张图、一个会出错的 场景。让读者先站稳,再开始学词。**不要用「本文和某某文档怎么分工」当第一节**:那种内容 对已经读过别的文档、正在纠结该翻哪份的人有用,对第一次打开的人是纯负担,把它放到词表后面去。 **二、词表不许挡在正文前面。** 具体是两件事:正文开始前不要列一串「这些词请先去别处看」; 词表本身如果超过十条,要在开头说清「哪几个现在就得记住、其余读到再回来查」。 **还要写明假设了什么背景知识**,而不只是「要先读哪几份文档」。读者读不懂的时候,得能判断是 自己缺背景还是文档写得烂——不写清楚,他只会怪自己。 这条没有机器能查,靠 `../CLAUDE.md` §3 那轮「硕士生阅读」评审时专门看一眼第一节。 **评审时要额外问一句:这份文档假设的读者是谁,项目负责人算不算在内。** CHSAnalyzer 那边 四轮评审都是以「相关领域的硕士生」的角色做的,它们能读懂那份并发文档,而项目负责人读不懂—— 这说明评审的读者假设定窄了,而文档自己也没把这个假设写出来。 ## 3. 记录层:`design/` 一份 design doc 记录一次决策:当时的处境、做了什么选择、否决了什么、代价是什么。 **必须在动工前写。** 不是因为流程要求,而是因为事后补写的方案文档会被已经知道的结果污染: 你会不自觉地把当初没想清楚的地方写得很笃定,把真正纠结过的备选方案一笔带过。那样写出来的 东西读起来像是一路顺理成章,也就失去了它唯一的用途——让后来的人看清当初在什么信息条件下 做的判断。 **命名**:`NNNN-短标题.md`,四位编号递增,例如 `0001-xxx.md`。用递增编号而不是日期前缀, 是为了让 `supersedes: 0001` 这样的引用能指向一个短而稳定的名字;日期前缀在文档之间互相引用时 又长又难记。 **目录为什么叫 `design/` 而不是 `adr/`。** 机制继承自 ADR 传统(见下一段),但这里装的不只是 架构决策——接缝取舍、公共类型的形状、验收口径的定法都放这里,而 `adr/` 这个名字会让人以为 只收架构类的东西。`design doc` 是 Google 工程实践里的叫法,覆盖面更宽。 **`design/` 是中间产物,不是最终产物——这一条最容易漏,而且漏了不会有任何提示。** 后续开发 对着的是**常青层**:写代码的人读 `explanation/`,不会为了写一行代码去翻 `design/`。所以一份 design doc 冻结的时候,它的结论必须已经落到两处之一: - **代码**(含它的测试),或者 - **常青文档**——如果代码还轮不到写。 **两处都没有,这份 design doc 就是死的**:它自己说决策已接受,而权威处(`../CLAUDE.md` §0 那张表)还写着老样子甚至写着「还没定」,于是照权威处读,这件事至今没定。**这不是「以后补」, 是当场就已经错了**——两层直接矛盾,而 §0 明令冲突时禁止自行调和。 **代码还没到写的时候,常青文档照样能写。** 常青层的职责是描述当前的真实情况,而「这个机制的 形状已经定了、代码还不存在」本身就是一种真实情况——写清楚形状,再写明它还没有代码、缺口在哪, 就够了。 **冻结与取代**:写完不改。决策变了就新写一份,在新文档开头标 `supersedes: 0001`,旧的原样留着。 这是从 ADR(Architecture Decision Record,架构决策记录,一种把每次架构决策单独存成一份不可修改 文件的做法)里保留下来的唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在, 你才能看出决策是怎么演变的。 **和 `explanation/architecture.md` 的分工**(这两份最容易搞混):改一次架构,两份都要动,但写的 东西不同。design doc 写「我们当时面对什么问题、比较了哪几个方案、为什么选了这个、放弃了什么」, 写完冻结。`architecture.md` 写「现在的分层长什么样、每层的职责和依赖方向是什么」,它永远只描述 当前状态,上一版的样子不在里面。简单说:想知道**为什么变成今天这样**去翻 `design/`,想知道 **今天到底是什么样**去读 `architecture.md`。 **什么算需要写 design doc**:改公共类型的字段、改 Protocol 签名、改主循环的停止语义、改分层与 接缝,以及任何「选错了要花很大代价才能改回来」的决定。判断标准是**能不能写出「否决的方案」**—— 如果这件事只有一种做法,那它不是决策,不用写。 **只有事实、没有决策的东西不属于这里。** 比如「conda run 的输出会被缓冲两层」,它不是我们选的, 是 conda 本来就这样。这类内容属于 `explanation/`,因为它描述的是一个不会变的机制,而不是我们 某个时刻的选择。 **`reference/agent-core.md` 不属于这里,也不属于任何一层。** 它是别人写的架构提案,不是我们的 决策记录——放进 `design/` 会让后来的人以为它被接受了。它留在仓库根目录的 `reference/` 里, 和五个参考仓库同级,理由见 `../CLAUDE.md` §0。 ## 4. `scratch/` —— 一次性文档 计划、草稿、调研笔记。进 git(方便协作时互相看见),但**由项目负责人在每轮工作会话结束前 手动清理**。 两点说明,因为这条规则完全靠人执行,含糊就等于没有: - **「每轮工作会话」**指一次连续的开发工作从开始到告一段落,通常就是和 AI 协作者的一次对话。 不是按 PR 算,也不是按天算。 - **清理的是人,不是 AI。** AI 协作者不要自动删 `scratch/` 下的任何东西——哪份草稿已经没用了、 哪份还要接着写,只有正在做这件事的人知道。 这里必须把风险讲明白:**这条规则没有任何机器兜底。** CHSAnalyzer 上一版的 `plans/` 目录攒到 49,222 行、2,757 个复选框其中 87% 永远没有被勾上,靠的也是同一句「记得定期清理」。之所以还是 这么定,是因为自动删除别人正在用的草稿风险更大。选择这条路就是接受了这个风险。 ## 5. 复述规则:论证可以复述,参数不许复述 文档写作里有两个常被混为一谈的目标,本项目明确区分它们: - **DRY(每个事实只写一次)**——这是**代码**的原则。用在文档上会伤害可读性,因为人类读者 希望在一份文档里把事情读懂,而不是在几份文档之间反复跳转。 - **权威来源(每个事实有唯一权威版本)**——这是**文档**的原则。它规定的是冲突时信谁, **并不禁止复述**。 Google 在他们的工程实践里对这一点说得很直接:「这常常导致一些信息的重复,但这种重复是有目的的: 为了清晰。」 所以本项目的规则是: > **论证可以复述,参数不许复述。** 区别在于会不会漂移。同一个道理在两处各讲一遍,两处不会互相矛盾——最多其中一处写得不如另一处好, 而读者少跳转一次是实打实的收益。但同一个数字写在两处,迟早有一处被改、另一处没改。 「参数」指的是**当前生效的取值**:阈值数字、路径、文件名、类型名、枚举取值、命令行的具体形状。 这些只在权威来源里出现一次,别处引用它。哪些事实的权威来源在哪,见 `../CLAUDE.md` §0 的表格。 **历史数字不算参数**,不受这条约束。比如「CHSAnalyzer 上一版的 `API.md` 落后代码 210 个提交」, 这个 210 是已经发生的事实,不会再变,也就不会漂移;它是论证的一部分,哪里需要就可以在哪里写。 会漂移的只有「当前生效」的那类值——因为它们将来会被改。 **代价是:** 允许复述论证,就等于接受了一种新的漂移——两处的**道理**打架。比如一处写「为了 可复现所以整份冻结」,另一处写「为了省内存所以只留引用」,两处都没有数字错误,但结论已经 矛盾了。这类冲突机器查不出来,只能靠 `../CLAUDE.md` §3 那轮独立评审。这是接受重复必须付的账。 ## 6. 文档质量为什么不进 CI CHSAnalyzer 曾经有过一个 `check_doc_consistency.py`,用正则断言文档之间的一致性,已经删除。 本项目不再造一个。 原因是它能查的(文件行数、目录树标注对不对)用一条 shell 命令就能看,而它查不了的 (这段话读不读得懂、这个决策有没有写理由)才是文档真正的腐烂形态。 这不是工程没做到位,而是原理上做不到。Google 在同一件事上的自我评价是:「测试可以自动化, 而文档自动化的方案往往是缺失的」,以及「文档必然是主观的;文档的质量不由作者衡量,而由读者 衡量,而且往往是异步地衡量」。质量的度量发生在读者脑子里,而且延迟发生,CI 拿不到这个信号。 所以文档质量走 `../CLAUDE.md` §3 的独立评审:一个没参与过讨论的 subagent,只拿到改后的文档, 纯靠文档读懂。先问它读的时候发生了什么,再问三类具体问题——哪句话读不懂、哪个决策只写了 结论没写理由、同一个参数在两处取值不同。