commit a2e94318b97ac519e6922906b8111a7ffa5521a5 Author: iomgaa Date: Fri Aug 7 07:02:09 2026 -0400 chore(repo): 建仓,落成协作规范与文档骨架 协作方式以 CHSAnalyzer 为蓝本,按「库」这个身份改写: - CLAUDE.md §0 的权威表换成公共 API 契约、公共类型、下游迁移三条主线, 数据库 schema / HTTP 契约 / alembic 迁移在本项目不存在,整体删去。 - §1 新增四条库特有的硬约束:字段只增不删不改名、持久化 schema 走显式版本、 不反向 import 下游、发布必须走完整流程(PolyGateway 有两个版本只 bump 没上传,registry 长期停在旧版且无人发现)。 - §3 的四类 Codex 对抗审查按同一判据重定:公共签名、停止判定与预算结算、 取消传播与 Session 隔离、schema 演进。共同点是错了不会当场炸。 - research-wiki 在常青层加第四类 migrations/,因为本项目的核心验收标准就是 能否搬回 dissect、能否替代 GovDoc-SaaS 的 docagent-core/,塞进 guides/ 会让它看起来像可选工序。 reference/ 不入库:五个仓库各带一个 .git、合计约 96MB,提交进来会变成一堆 不可用的嵌套仓库。它也不是任何事实的权威,agent-core.md 同理。 Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cedd4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 参考资料:五个克隆下来的仓库 + agent-core.md(CLAUDE.md §0)。 +# 它们各自带着自己的 .git,提交进来会变成一堆不可用的嵌套仓库, +# 而且体积接近 100MB。需要的人自己按 README 的表格克隆。 +/reference/ + +# 密钥永不入库(CLAUDE.md §1);.env.example 只放键名和说明。 +.env +.env.* +!.env.example + +# 运行产物 +/data/ +/logs/ +/tests/outputs/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +/build/ +/dist/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..15a9e13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,158 @@ +# PolyLoop + +实验室共用的 Agent 执行内核。治理单位是**一次 Agent Session**:围绕一个目标的有界多轮「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。一次模型调用本身不归它管,那是 PolyGateway 的治理单位;PolyLoop 用 PolyGateway 的顶层公共 API,不重建一套模型治理。 + +首批消费者是 dissect 与 GovDoc-SaaS,CHSAnalyzer 是远期消费者。 + +回复用简体中文;代码与标识符用英文。**commit message 是「英文前缀 + 中文正文」**,形如 `feat(session): 停止判定顺序落成代码`(前缀是 `类型(范围)` 那套约定,范围可省)。 + +> **这是库,不是应用。** 它的 bug 会同时击穿所有下游项目,所以稳定性、并发正确性、防御校验、可观测与测试不为「简单」让步——YAGNI 仍然适用,但不削减健壮性。 +> **能交给机器的就别靠自觉**——能写成 CI、ruff 规则、import-linter 契约或测试的就去写,写不出来的至少要能在 §3 那轮评审里被指出来。本文件本身只是给协作者(人和 AI)的上下文,不是强制层,真要拦住某个动作得靠 CI 或 hook。 +> **本文件不放临时内容。** 会过期的东西(当前阶段、进行中的迁移、临时约定)放到它自己的权威处,这里只留一条指向那里的常青规则——否则过期条文会留在这里没人记得删。 + +--- + +## 0. 事实的解释权(冲突时按此裁决,**不要自行调和**) + +| 这类事实 | 权威处 | +|---|---| +| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,将由 `pyproject.toml` 的 import-linter 契约机器断言。**那些契约还没写**(要等 `src/` 落地才写得出来),在它们出现之前这份文档就是权威,没有机器兜底 | +| 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 | +| 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 | +| 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 | +| 已定的决策及其理由 | `research-wiki/design/` 下相关编号最大的那份 | +| 某个机制、约束、坑为什么是这样 | `research-wiki/explanation/` | +| 其余查得到的事实:日志字段契约、遥测口径 | `research-wiki/reference/` | +| **当前进度:处在哪个阶段、哪些已完成** | `README.md` 的阶段清单 | +| 已发布的版本与每版改了什么 | `CHANGELOG.md` | +| 文档体系怎么组织、新文档该放哪 | `research-wiki/README.md` | +| 协作规则 | 本文件 | + +**`reference/` 不在上表里,因为它不是任何东西的权威。** 那里的五个仓库和 `agent-core.md` 地位相同,都是参考资料。`agent-core.md` 是别人为本项目写的一份架构提案,它不是我们的设计,也不是常青文档——其中任何一条在被我们自己的 design doc 明确采纳之前都不作数。引用它时必须写成「agent-core.md 的说法是……」,不能写成「我们决定……」。代价是每次多写一句话,收益是不会长出「大家都以为这个决定已经做过了」的状态——那种状态在上表的裁决规则下最难修,因为它没有一个错的地方可以指。 + +`reference/` 只读、不入库,也不改。 + +**复述规则:论证可以复述,参数不许复述。** + +同一个道理在几处各讲一遍是好事——人类读者希望在一份文档里把事情读懂,而不是在几份之间反复跳转,而论证不会漂移,最多某一处写得不如另一处好。但同一个**参数**(数字、路径、文件名、类型名、枚举取值、命令行的具体形状)只在上表的权威处出现一次,别处引用它:那种东西迟早会有一处被改、另一处没改。 + +代价是会出现另一种漂移:两处的**道理**打架(一处说「为了可复现所以冻结」,另一处说「为了省内存所以只留引用」)。机器查不出来,靠 §3 那轮独立评审兜。展开见 `research-wiki/README.md`。 + +**为什么冲突时禁止自行调和。** 把两边捏合成一个折中说法,看起来是负责任,实际上会生出第三个没人认过的版本,而且把「有一处已经漂移了」这个真正需要修的信号盖掉了。按表裁决则相反:它逼你去改错的那一处,漂移当场被消灭。 + +**本文件只管协作约定,不管项目事实。** 与上表任一文件冲突时以那边为准,并顺手把本文件改对。改本文件本身不需要请示——但如果改的是 §1 的硬约束或 §2 的人类门,先说一声再动。 + +## 1. 硬约束 + +1. **零业务假设。** 库内禁止出现下游的业务词汇(公文、审核点、超声、CHS、benchmark、实验轮次、得分)与业务 fixtures;扩展点一律用 Protocol。三个下游的领域互不相交,一个业务词进来就等于替其中一个项目做了另外两个不需要的假设。这类假设很难删——它会长出配套的字段、分支和测试,删的时候要一起动。 +2. **不反向 import 任何下游项目。** 由 import-linter 契约断言(契约还没写,见 §0 第一行)。 +3. **公共类型的字段只增不删不改名,新增字段必带默认值。** 三个下游各自 `pip install` 本库,改名会让已经在跑的代码直接 `ImportError` 或静默拿到默认值。要删要改就发新 major 并写迁移指引。 +4. **持久化结构的 schema 变更走显式版本,不靠默认值补齐。** 会被下游存进数据库或实验数据集的结构(运行结果、逐步轨迹)必须带独立的 schema 版本,读到未知 major 直接失败。dissect 的轨迹是论文实验数据,一次静默的默认值填充会把「这件事没发生过」改写成「发生了但值为空」,而这种损坏要到统计阶段才暴露,那时已经分不清哪些行是真的。 +5. **模型调用一律走 PolyGateway**(实验室共用库)。不在本项目里另写重试 / 限流 / 熔断 / 缓存 / 遥测。缺能力就给 PolyGateway 提 PR。 +6. **`asyncio.CancelledError` 永不捕获吞没。** 取消要能穿过模型调用与环境执行,in-flight 资源在 `finally` 释放。吞掉它的后果不是「取消失败」这么直白——是容器租约、连接和临时目录持续泄漏,而且一声不吭。 +7. **禁止吞掉错误**(`except Exception: pass` 及其跨行形态)。由 ruff `S110` / `E722` 断言。 +8. **测试绑行为,不绑实现。** 不写「断言某个内部类有哪些方法」这类测试——它只会让重构连坐。 + **公共 Protocol 的签名是例外**:它本身就是对下游的承诺,不是实现细节,所以 `tests/contract/` 断言它是应该的。判据是这个名字有没有对外承诺过——承诺过的改名是破坏性变更(§1.3),断言它就是在守那条承诺;没承诺过的改名只是重构,断言它就是在拖后腿。 + **断言某个名字「不存在」也是允许的**,用来守住一次删除决策。一个已经被删掉的字段没法被重命名,拖不动测试。代价是它守的只是名字不是概念——换个名字把同一个概念加回来,测试照样绿,所以理由必须同时写在被删字段所在类型的 docstring 里。 +9. **测试分层按「依赖什么」定,不按「叫什么」定。** 用测试替身的是 unit,连真 PolyGateway 的是 integration,打真实模型网关的是 e2e,验证公共 Protocol 行为一致性的是 contract。按名字分层的话,改个函数名就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。四层之间更细的界线在搭测试框架那个阶段定,现在不必较真。 +10. **发布 = 合并 + push + tag + 构建 + 上传 registry + 验证已发布。只 bump 版本号不叫发布。** 教训来自 PolyGateway:1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。完整步骤见 `research-wiki/guides/`(还没写)。 +11. **动手前先看 README 的阶段清单。** 不要为了还没到的阶段提前写大量代码,也不要为假设中的工作量预先埋好一堆结构——这就是 §6 YAGNI 的意思,只是在阶段这个尺度上再说一次。 + +## 2. 人类门(仅以下需要用户批准,其余自行判断) + +| 场景 | 为什么 | +|---|---| +| 改公共 API:公共类型的字段、Protocol 签名、停止原因的取值、停止判定顺序、持久化 schema 版本 | 三个下游按它写代码,错了会静默扩散,且改动成本随时间指数上升。**先写 design doc,等确认再动手** | +| 任何外部可见动作:push、开 / 关 PR、动别人的分支 | 涉及协作者 | +| 发布(含打 tag 与上传 registry) | 下游一旦装上就收不回来 | +| 删除或覆盖既有数据 | 不可回滚 | + +**「改公共 API」指的是改变已有承诺的形状**——新增一个可选组件并同时补上它的契约测试,属于「写代码 + 补测试」,自行判断即可。区别在于前者会让已经在用的调用方静默失败。 + +其余(写代码、跑测试、写文档、重构、补测试、写 design doc 草稿)**无需请示;何时先讨论设计由你判断**。 + +**外部 PR 一律不直接 merge。** 逐段审阅:符合本仓库规范的代码直接复用,不符合的按规范改写;无论哪种,落地结果必须与原 PR **功能等价**,并在提交信息里标明来源 PR 与作者。理由是本仓库靠一套机器可检查的约定维持一致性,而外部分支不在这套约定下产生。 + +## 3. 审查规则 + +**四类高风险产物必须 Codex 对抗审查**(`/codex:rescue --fresh --wait`): + +1. 公共类型与 Protocol 签名的任何变更; +2. 主循环的停止判定与预算结算; +3. 取消传播,以及并发 Session 之间的状态隔离; +4. 持久化结构的 schema 演进与反序列化。 + +这四类的共同点是**错了不会当场炸**。签名改错要等下游升级那天才发现;停止判定顺序错了,「恰好在最后一步做完」会被记成「预算耗尽」,而两者的轨迹长度一模一样;取消漏掉一处只表现为资源占用慢慢往上涨;schema 靠默认值补齐要到统计阶段才看得出来。这类问题人眼复核的命中率很低,因为它们没有失败现场。 + +Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。**之所以必须换它、而不是再开一轮自家 subagent**:同一个模型的盲区是一致的,它审自己写的东西,会以同样的理由漏掉同样的问题。换一个不同来源的模型才可能戳破这层偏见,也能挡住单个模型偶尔的抽风。「对抗」指的就是让它专门去挑毛病,而不是让它确认我们做得对。 + +**其余代码**完成后至少一轮**独立 subagent 新鲜上下文审**:prompt 只给 diff、验收标准和相关文档,**不给实现时的推理过程**。给了推理过程,它会顺着我们的思路复核一遍,只能验证「按这个思路做得对不对」,验证不了「这个思路本身是不是错的」。 + +**文档大改必须过一轮独立 subagent 的「硕士生阅读」。** 触发条件:新写一份文档、重写既有文档的整节、或单份文档改动超过约 100 行。开一个新鲜上下文的 subagent,**只给它改后的文档,不给我们的讨论过程、不给相关代码**——它必须纯靠文档读懂。**先问它读的时候发生了什么,再问它查到了什么**,两问的顺序不能反。 + +第一问是**阅读行为**:哪几段你跳过去了、读到哪儿开始走神、合上文档能不能把这套东西复述一遍。**跳读和走神是行为,不是意见**,所以它们不受下面那条「不采纳风格建议」的约束——一个读者跳过了某一段,那就是一个关于这段文字的事实。这一问是 CHSAnalyzer 踩出来的:那边的文档里有整段只在讲文档自己(「这一节把整份文档串成一个故事」这类),前面跑过两轮的审查一条都没报,因为当时的 prompt 明令它不许报这类,于是这一整类问题对这道闸天然不可见。 + +第二问才是三类具体问题:**哪句话读不懂、缺了什么前置知识**;**哪个决策只写了结论没写理由**;以及**同一个参数(数字、路径、类型名、命令形状)在两处取值不同**。前两类主观,但那是它们的性质;第三类是确定性判据,报了就是真的。格式、措辞、结构建议一律不采纳——每次都能挑出十条建议,等于没有建议,这轮评审很快就会被跳过。参数一致性不另开一轮检查,也不写成脚本:按 §0 的复述规则参数本来就只在权威处出现一次,撞车机会很少,专设一道检查会长期空转,而空转的检查很快就会被跳过。 + +**审查反馈只采纳影响正确性或明确需求的项**;风格类建议自行取舍,防过度工程。结论有分歧时,**以「能否指出具体失败场景」为准**。 + +## 4. 环境与运行 + +- Conda 环境 `PolyLoop`(**还没建**),Python 3.11。3.11 不是选出来的,是被下游钉死的:dissect 和 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。 +- **Python 命令一律用这个形状**:`PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop `。conda 和 Python 各缓冲一层,两层都得拆:只加 `--live-stream` 或只加 `-u` / `PYTHONUNBUFFERED` 都仍然全程无输出,直到进程结束才一次性吐出。六种组合的实测与原理见 `reference/CHSAnalyzer/research-wiki/explanation/conda-run-output-buffering.md`(同一台机器、同一套 conda,结论直接适用)。 +- **超过约一分钟的命令(测试套件、压测、真实网关回归)必须放进 tmux 跑**,不要阻塞在前台,也不要只丢进后台。tmux 会话人和 AI 都能 attach,可以一起看同一份实时输出、随时中断。会话按用途命名(如 `polyloop-e2e`),跑完不要急着 kill,留着给人复查。 +- **长跑命令末尾不得接管道。** `pytest ... | tail` 的退出码来自管道最后一节,于是失败的测试跑会报成 exit 0。要判断完成用 `wait` 或轮询 PID,**不要用 `pgrep -f "<完整命令串>"`**——它会匹配到自己,形成永不结束的等待。这两条是 PolyGateway 实测撞出来的,两种失败都以「看起来还在跑」的形态呈现,从外部区分不了。 +- **本库不部署,也不跑模型推理。** 所有模型调用经 PolyGateway 出去(§1.5)。**这台机器是和别人共用的**,本仓库现在没有任何用得着 GPU 的代码;**但只要哪天有了,那条命令就必须显式加 `CUDA_VISIBLE_DEVICES=`**,省略会自动选卡,占掉别人正在用的显卡。 + +## 5. 目录说明(★ = 已存在,其余为规划) + +只列需要解释的。`src/`、`tests/`、`.github/workflows/` 这类看名字就知道装什么的不列。 + +``` +★ reference/ 参考资料:五个仓库 + agent-core.md + 只读、不改、不入库,且不是任何东西的权威(§0) +★ research-wiki/README.md 文档体系怎么组织、新文档该放哪 +★ research-wiki/design/ 动工前的方案与权衡,只增不改;决策变更 = 新写一份标 supersedes +★ research-wiki/explanation/ 为什么这样设计(常青,须写明更新触发点) +★ research-wiki/migrations/ 每个下游项目迁走什么、迁完算不算数(常青) +★ research-wiki/guides/ 怎么做某件事:发布、本地环境、排障 +★ research-wiki/reference/ 查得到的事实:日志字段契约、遥测口径 + (公共类型和枚举取值不在这里,权威见 §0 表格) +★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删) +★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), + 也是任何新适配器的准入标准。目录已建、测试还没写 + src/polyloop/ 库本体 +``` + +常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`。 + +硬性规则:根目录不得出现 `.py`;禁止 `helpers/`、`common/`、`shared/`、`misc/`、`utils/` 这类目录名——它们的职责是「剩下的东西」,一句话说不清职责就没有边界,最后什么都往里塞。 + +## 6. 代码与文档规范 + +- **YAGNI**:不写当前用不到的代码。但健壮性(并发控制、防御校验、可观测、错误隔离、测试)**是当前需要**,不在削减之列。抽象只在真正易变 / 需替换 / 需造测试替身的接缝处引入。 +- **显式优于隐式**:公共函数完整类型注解;依赖注入,不从全局偷取;不用默认参数掩盖关键逻辑。这条在库里比在应用里重一档——下游看不到实现,只能靠签名和类型判断该传什么。 +- **一切外部输入校验后使用**:模型返回、适配器返回、配置都算外部输入。校验用显式异常,**`assert` 只用于内部不变量**,不承担生产校验——Python 的 `-O` 会把 assert 整条移除,下游用 `-O` 跑的那天校验就静默消失了。 +- **文档的目标读者是「没参与过我们讨论的相关领域硕士生」。** 自造词在首次出现处就地解释;**每个设计决策都要写清楚「为什么这么定」**——只写结论不写理由的文档,过几天连我们自己都看不懂。 +- **以人类可读为准,不以信息密度为准。** 禁止:一句话套三层因果;用箭头链(`A → B → 失败`)代替句子;把论证塞进表格单元格(表格只放事实和数字,论证放正文段落)。 +- **不写导航句,也不先宣布自己要讲什么。** 不告诉读者该按什么顺序读、哪一节可以跳过、这一节接下来要讲什么。该讲的直接讲——「这一步反直觉,得解释」删掉之后解释还在那儿,反不反直觉读者自己会判断。**指路是另一回事,照写不误**:「见第五节」给的是位置,不是对内容的预告。 + 这条靠自觉,而且照着它也写得出合规的废话;真正管用的是 §3 那一问。**不要试图把它写成机器检查**——CHSAnalyzer 实测过:「本节」这个词在三份常青文档里出现十处是合法的指路、七处是自述,一半误报的检查活不过两周。 +- **常青文档只用「陈述系统」这一种语气。** 句子的主语是系统里的东西(这个字段、这个策略、这条规矩),不是「这份文档」「这一节」「这里」。一旦动词变成写作动作——不复述、列出来、说清楚、正面写、免得读者——就走音了,**哪怕那句话本身有道理**。改法是把主语换回系统:「代价要说清楚:X」写成「代价是:X」。 + 这条和上一条是同一族的两个种:上一条是**先宣布自己要讲什么**,这条是**解释自己为什么这么写**。**两条都不能用「删掉之后信息有没有少」来判**——那种句子往往真的带着信息,按内容判会把它留下来,而它照样读着别扭。判据在语气,不在内容。 +- **代码里的 docstring 和注释同理,判据是「读这段代码的人不知道就会写错什么」。** 不要把 design doc 的论证整段抄进来——那是 `design/` 的职责,指过去一行就够。约束某一处代码的话就写在那一处。**例外是那些 design doc 点名要求写进代码的**,以及 §1.8 那种「被删掉的字段为什么删」。 +- 文档长度上限:`design/` 与 `explanation/` 下的单份文档 ≤600 行,`guides/` ≤400 行。这两个数没有理论依据,取的是「一次能读完、不必分几天啃」的经验值。**超了不是「必须拆」,是「必须停下来检查这份文档是不是在讲不止一件事」**——确认是就拆,确认不是就在文档开头写一句为什么不拆。`design/` 判断可以再宽一些,因为它是「我想知道当初为什么这么定」时跳进去看**某一个决策**的,很少有人从头读到尾。`reference/` 与 `migrations/` 不设上限——字段表、删除清单本来就该写全,砍长度只会让它变得不可信。 + +## 7. 工作方式 + +- **交付被请求的范围。** 常规判断自己做;只有当不同理解会导出实质不同的工作时才来问。觉得请求有问题就用一两句说出来,然后按原样继续做,**不要悄悄地缩小、放大或改造它**。 +- **报告进展前,逐条对照本次会话真实的工具结果。** 只报告拿得出证据的部分;没验证的明说没验证。测试挂了就贴输出;跳过的步骤就说跳过了;做完并验证了就平实地说清楚,不要模糊其辞。 +- **不做没让做的事**:不顺手重构、不为假设中的未来需求加抽象。修 bug 不需要顺带清理周边。 +- **不建防御性备份分支。** 想留个后路的心情可以理解,但分支一多就没人认得出哪条还有用,最后谁都不敢删。git 本来就留着历史,需要回退随时回得去。 + +## 8. 对话 + +说人话。像同事聊天那样一次说一件事,别把一轮回复写成报告。你是我的合作者,不是一个机器,不要把一大堆内容直接甩给我自己分析,这是推卸责任。我们的目标是一起通力合作开发好这个项目。 + +问什么答什么,有判断直接讲,讲完停下来等回应,不要一口气把后面几步都推完。不要默认一些名词和你搜索到的内容我是一定知道的,你有讲解的义务。不要为了「扮演」专业刻意使用高信息的句子或者表述,这会显著降低可读性。 + +**要我做决定时,一次把决定需要的信息给全。** 具体说:总共几个问题、每个问题有哪些选项、你倾向哪个、以及哪些是你自己就能定的。**不许挤牙膏**——先讲三条、等我追问才补上剩下九条,这中间我是在信息不全的情况下做判断,等于白问。你看得到全部上下文,我看不到;你不列全,我就没有选的依据。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..62568b3 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# PolyLoop + +实验室共用的 Agent 执行内核。治理单位是**一次 Agent Session**:围绕一个目标的有界多轮 +「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。 + +它和 PolyGateway 是叠起来的两层。PolyGateway 治理**一次模型调用**(多源、限流、重试、熔断、 +缓存、遥测),PolyLoop 治理**一次 Agent Session**,并用 PolyGateway 的顶层公共 API 拿模型。 +任务编排、批量调度、评分、检索、Skill 的生成与进化都留在下游项目。 + +--- + +## ⚠️ 项目尚未开工(2026-08-07 起) + +当前仓库只有协作规范和文档骨架,`src/` 一行代码都没有。下面的阶段清单是唯一的进度权威。 + +## 消费者与验收标准 + +| 项目 | 现状 | 本库对它的验收标准 | +|---|---|---| +| dissect | 已有跑着的 `harness/agent/`(loop / context / memory / parser) | **能把那套循环搬到本库上,dissect 原有测试全绿。** 一手需求证据最强 | +| GovDoc-SaaS | 已有 `packages/docagent-core/`(第一次抽库尝试,含 agent / workflow / retrieval / taskrun) | **能替代掉 `docagent-core/agent`,能替代更多更好。** 哪些子包能一并接管,在第 ② 阶段判断 | +| CHSAnalyzer | 还没写到 agent 那一步,只有设计方案 | **远期可以兼容使用。** 它的 agent 需求要么本库能满足,要么明确写进「不属于本库」清单并说明为什么 | + +三者的证据强度不同,能进本库的语义也就分档:dissect 和 GovDoc-SaaS 的真实代码是一手证据, +两边都需要的机制才有资格做成稳定内核;CHSAnalyzer 只用来检验边界画得对不对,不能凭它的 +设计方案单独长出一个组件——一个没有真实调用方的抽象,等到有调用方那天多半是错的。 + +## 阶段清单 + +**这份清单是「当前处在哪个阶段」的唯一权威。** `CLAUDE.md` 不重复这里的内容,只有一条常青规则 +(§1.11)要求动手前先看这里——这样阶段推进时不需要改 `CLAUDE.md`,开发结束后把本节删掉即可, +不会在别处留下过期条文。 + +- [x] ① 协作规范 —— 见 [CLAUDE.md](CLAUDE.md) 与 [research-wiki/README.md](research-wiki/README.md)(文档体系) +- [ ] ② 需求对齐 —— 从 dissect 的 `harness/agent/` 与 GovDoc-SaaS 的 `packages/docagent-core/` + 提取真实需求,产出 `research-wiki/migrations/` 下两份迁移文档(删除清单 + 组件映射 + 验收口径), + 并检查 CHSAnalyzer 的 agent 方案落在边界内还是边界外。**这一阶段的产物决定库的边界**, + 所以它排在架构前面:边界画错,后面每一份架构文档都要重写 +- [ ] ③ 架构 —— `research-wiki/explanation/architecture.md` 与 `pyproject.toml` 的 import-linter 契约。 + 架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点 +- [ ] ④ 测试框架 —— unit / integration / e2e / contract 四层骨架(划分判据是「依赖什么」, + 见 [CLAUDE.md](CLAUDE.md) §1.9),以及 `tests/contract/` 那套公共行为一致性用例的形状 +- [ ] ⑤ 实现 —— 分块落地,每块必须有对应层级的测试接住,守着它的 import-linter 契约在同一个提交里加上 +- [ ] ⑥ 迁移验收 —— 真的把 dissect 与 GovDoc-SaaS 迁过来,以两边测试全绿为准 + +## 本地检查 + +CI 会跑的几项,本地随时可以自己跑(**命令与工具链尚未落地,等第 ③ 阶段**)。 + +文档质量不走 CI,走 [CLAUDE.md](CLAUDE.md) §3 的「硕士生阅读」评审。 + +## 参考资料 + +`reference/` 下的五个仓库与 `agent-core.md` **都只是参考,不是本项目的设计,也不是任何事实的权威** +(理由见 [CLAUDE.md](CLAUDE.md) §0)。它们只读、不改、不入库。 + +| 位置 | 是什么 | +|---|---| +| `reference/agent-core.md` | 别人为本项目写的一份架构提案。其中任何一条在被我们自己的 design doc 采纳前都不作数 | +| `reference/dissect/` | 消费者,已有 ReAct 循环实现 | +| `reference/GovDoc-SaaS/`(`background` 分支) | 消费者,已有第一次抽库尝试 `packages/docagent-core/` | +| `reference/CHSAnalyzer/` | 远期消费者;同时是本仓库协作规范的蓝本 | +| `reference/PolyGateway/` | 本库的依赖,也是「实验室共用库该怎么做」的蓝本 | +| `reference/pi/` | 外部参考实现 | + +其余约定见 [CLAUDE.md](CLAUDE.md),那里是协作规则的唯一来源。 diff --git a/research-wiki/README.md b/research-wiki/README.md new file mode 100644 index 0000000..d4ce0e1 --- /dev/null +++ b/research-wiki/README.md @@ -0,0 +1,287 @@ +# 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,只拿到改后的文档, +纯靠文档读懂。先问它读的时候发生了什么,再问三类具体问题——哪句话读不懂、哪个决策只写了 +结论没写理由、同一个参数在两处取值不同。 diff --git a/research-wiki/design/.gitkeep b/research-wiki/design/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/explanation/.gitkeep b/research-wiki/explanation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/guides/.gitkeep b/research-wiki/guides/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/migrations/.gitkeep b/research-wiki/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/reference/.gitkeep b/research-wiki/reference/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/scratch/.gitkeep b/research-wiki/scratch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/.gitkeep b/tests/contract/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29