规定体系

状态:草案。这里的内容都还没有实现。本文档描述规定体系建成之后,规定怎样编写、编号、选用、检查和编译。凡与设计其余部分目前的说法不同之处,都列在下文的它将取代什么;那些部分保持原样,等到实现本设计的工作再去修改。

目录

术语

  • 规定:细化宪法某一条文的一项要求 (division-of-instructions.rules),附有判断它是否被违反的方法。
  • 规定源:存放规定的仓库。本仓是一个;自己编写规定的团队把规定放在自己的规定源里 (见规定源与引用)。
  • 规定清单:一个仓库选用的规定,写成一个列出规定 ID 和规定包的配置文件;它可以继承另一份规定清单。
  • 择一组:parent 树上的一种节点,它的子节点是互相排斥的备选项 (见依赖、择一组与规定包)。
  • 锁文件:写在规定清单旁边;它固定每条选用规定的修订号,并把每个规定源解析到一个确切的提交。
  • 检查:针对一条规定,判断一个仓库、一次变更或一份记录是否合规的代码。
  • 审查方:对检查定不了的情况作出判断的 agent。

一条规定包含什么

一条规定只要求一件事,而且是否违反可以判定:给定一个仓库、一次变更或一份记录,有人能回答「这条规定有没有被违反?」。它就像 linter 里的一条 lint 规则,范围窄到可以检查,这正是 decidable-provisions 对每条下位法条文的要求。

正文三到五句话:要求、理由,以及怎样检查是否合规。需要写更多的规定,通常是把几条规定写成了一条,应当拆开。

ID 与修订号

规定的 ID 是一个数字,按顺序分配,永不重复使用。预计规定会增长得很快,而且主要由 agent 编写和检查,所以人不会逐条细读;用数字可以免去为起名争论,措辞改变时也从不需要改 ID。

规定永不删除。一次修改保不保留 ID,取决于一个问题:它有没有改变什么算合规?

  • 没有改变,就原地修改这条规定,修订号加一。改写措辞、增加一个例子、修复检查里的一个 bug:原来合规的仍然合规,原来违反的仍然违反。
  • 改变了,修改后的规定就取一个新 ID。旧规定标为被取代,写明取代它的规定,并永久留在规定源里。只有同一规定源的规定才能取代它 (见规定源与引用)。

所以一条规定的 ID 始终代表同一项要求。修订号是每条规定各自的一个数字,从 1 起算;它与宪法的修订号无关。规定的状态是 draft、trial、active、superseded 或 repealed 之一。宪法还是草案时,每条规定都是 draft;trial 表示这条规定在定型之前正在实际工作中试用 (见试行规定)。撤销而没有替代的规定为废止,它与被取代的规定一样,保留自己的 ID 和文件。

规定清单与锁文件

规定清单列出规定 ID (以及规定包,见下文)。它的锁文件为每条选用的规定记下所用的修订号,以及它所来自的规定源的提交,所以同一份规定清单和锁文件总是构建出同样的输出。

升级是一个明确的步骤:重写锁文件,并显示每条修订号有变动的规定的 diff。规定清单里写了被取代的规定时,构建会指出这一点,并写出它的后继规定;一条命令就能在规定清单里把旧 ID 换成新 ID。下游用户不必手动顺着取代链往下找,在他们升级之前,他们那边什么都不会变。

绑定一部宪法。 规定清单写明它依据的宪法:来自哪个仓库、哪一版 (edition)、哪个修订号 (revision)。锁文件像固定规定源的提交那样固定这个修订号,一次编译只用一部宪法的一个确切修订号。依据不同宪法的规定清单不会合在一起用:仓库和版至少要一致,修订号则对照每条规定来检查 (见从规定追溯到宪法)。宪法修订后,钉在旧修订号上的规定清单不会变;把它移到新修订号上,和别的升级一样是一个明确的步骤。继承了基础规定清单的清单,钉的修订号可以与基础清单不同;这时构建取其中较新的那个,如果有已启用规定的范围容不下它,构建就失败。

按仓库划分,可以继承。 规定清单属于一个仓库,它可以继承一份共用的基础规定清单,这样一个用户或团队的所有仓库都认可的规定只写一次。仓库在基础清单之上增加规定、去掉规定,并在择一组的备选项中作出选择。这样,当前设计中的法域 (division-of-instructions.rules) 就成了一份基础规定清单的所有者,它的各个仓库继承这份清单;每个仓库的规定清单和编译输出,取代每个法域一份的规定选择和编译文件。互斥按规定清单检查,所以一个仓库可以用 uv 管理它的 Python 包,另一个用 Bazel。

monorepo。 规定清单可以按路径分成几段,每段指明一个目录和适用于它的规定。互斥在每一段内检查,编译器把每一段的指令文件写进对应的目录,所以 monorepo 的一部分可以用 uv,另一部分用 Bazel。这取决于各个 agent 怎样读取子目录里的指令文件,而这一点各个 agent 不尽相同,这里也还没有测过。实现时要先查的,就是每个支持的 agent 在子目录里工作时会不会读那个子目录的指令文件;查清之后才定下分段的设计。

放在哪里。 这套设计仿照 uv 这样的包管理器:规定源相当于包索引,规定清单相当于项目 manifest,锁文件就相当于锁文件,编译相当于 sync,用户级缓存相当于包缓存。仓库里放规定清单、锁文件和编译输出;升级是在仓库里执行的一条命令,结果以一个 pull request 的形式出现。用户级只放工具、规定源的缓存,以及很薄的一层个人设置,存放与人有关、与项目无关的内容:回复用什么语言、把消息当作语音转文字来读、凭证存放在哪里、机器上的目录怎样布局。这一层接管设计中已有的私有 overlay 里与个人相关的那部分。每条规定声明一个默认层级 (项目规定在仓库级,关于人或机器的规定在用户级),用户可以修改。这样做的原因是:克隆仓库的人、CI 和云端的 agent 都读不到用户级的文件,而且每个仓库固定自己的版本,按自己的节奏升级。

规定源与引用

一条规定由它的规定源和 ID 确定。提交不属于这个标识的一部分。

给规定源起名。 规定清单在 [sources] 表里给每个规定源起一个简短的本地名字:

[sources]
ac = "github.com/Luolc/agent-constitution"
team = "example.test/their-team/rules"

引用写成名字、一个冒号和规定的 ID:ac:42、team:7。名字只在定义它的文件内有意义,所以两份规定清单可以用 ac 指不同的规定源。规定源搬到别的地址时,规定清单改一行,引用一个都不用改。一个规定源如果引用了别的规定源里的规定,它也有自己的 [sources] 表,形式相同,其中的名字不必与使用它的任何规定清单一致。构建按地址而不是按名字匹配规定源:一个文件里的 ac 和另一个文件里的 core 指向同一个地址时,就是同一个规定源。

版本记在规定源上。 规定清单写明跟随一个规定源的哪个 tag 或分支;锁文件把它解析到一个确切的提交。这也是 pyproject.toml 和 uv.lock 的分工:manifest 写明一个范围,锁文件记下确切的版本。引用不带提交,原因有两个。一是升级就得改每一处引用。二是如果同一规定源的两条规定固定在不同的提交上,它们之间的 requires 或 conflicts 就会指向一条与选用的那条不同的规定。所以一份规定清单对每个规定源只取一个版本。

跨规定源的依赖。 一个规定源的规定可以在 requires 下列出另一个规定源的规定,例如 ac:12。这时依赖方所在的规定源要在自己的 [sources] 表里声明,它适用于那个规定源的哪些版本,写成一个范围。编译时,构建为每个规定源寻找一个被所有声明都接受的版本。规定清单自己跟随的 tag 或分支也算作其中一项声明。找不到这样的版本,构建就失败,并指出相互冲突的声明。

取代只在规定源内部发生。 只有规定源自己能取代自己的规定。别的规定源不能声明它的规定替代了我们的规定。它可以在自己规定的正文里推荐这个替代;换不换,由用户在自己的规定清单里决定。

第三方的规定。 用户可以启用自己选定的规定源里的规定,比如某个团队自己的规定源,或者别人的公开规定源。这是用户的权利,风险也由用户承担:第三方的规定不经过本仓规定的那套审查,却会左右读取编译结果的 agent。内置规定写明,来自本仓之外的规定源的规定和 skill,启用前要怎样检查。默认情况下,用户用默认的宪法和默认的规定清单,不引入任何第三方规定源。引入第三方规定是高级用法,细节以后再写;宪法里不写这些。

查找公开的规定。 这件事不需要有人运营一台中心服务器。可以用一个索引仓库列出已知的公开规定源,就像 Homebrew 列出它的 tap 一样,网站上的完整列表也可以从这个索引构建。这一步放在后面做,不属于第一个版本。

规定与 skill

规定说明必须达到什么状态;skill 说明怎样做到:步骤、模板、命令、已知的坑 (skills)。别人能指着说「你违反了这一条」的内容,写进规定;只是帮 agent 把事情做对的内容,写进 skill。

skill 声明自己落实哪些规定,只有当其中至少一条在规定清单里时才安装。仓库的 AGENTS.md 只保留这个仓库特有的内容 (它是什么、用什么语言、目录结构、检查命令),并指明它使用的规定清单 (division-of-instructions.project-instructions)。规定清单按仓库选定 (规定清单与锁文件)。

依赖、择一组与规定包

规定之间有两种关系,树上还有第二种节点:

  • parent:每条规定恰好有一个 parent,它可以是另一条规定、一个择一组 (见下文),或者树顶的某个领域 (Python、CI、Git、安全等),所以规定构成一棵树,每个领域逐级细化。这棵树决定一条规定在编译出的文本和网站上的位置、它编译进哪个块,以及它属于哪个规定包。
  • requires:启用规定 A 需要另一条规定 B,或者一个择一组 (见下文)。一条规定可以依赖好几条,这些依赖构成一个有向无环图。一条说明怎样通过 uv 运行检查的规定,依赖让 uv 管理项目依赖的那条规定。也可以依赖另一个规定源的规定;见规定源与引用。

只有 parent 不够,因为许多规定横跨几个领域:「在 CI 里先运行 uv sync --locked」既依赖 uv,也依赖 CI 服务。它放在 uv 那条规定下面,并依赖择一组「CI 服务」,这个择一组的任何一个备选项 (例如一条关于 GitHub Actions 的规定) 都能满足它。

择一组。 择一组是 parent 树上的一种节点,它的子节点是互相排斥的备选项:在一份规定清单 (或者它的一个路径分段) 里,至多启用其中一个子节点。Python 下的择一组「Python 包管理器」有 uv、Poetry 和 Bazel 三个子节点。择一组有 ID,增加一个择一组和增加一条规定一样要经过审查。依赖择一组的规定,只要它的任何一个子节点启用,就算满足。

后来才发现的冲突。 没人当作备选项的两条规定,可能后来发现不能一起用。怀疑这一点的 agent 报告它,由审查方判断。通常的解决办法是修改规定:拆分、收窄或者改写措辞,保不保留 ID,见ID 与修订号。两条规定都没有问题、只是不能一起用时,在其中一条上写 conflicts = [ID]。这个关系是对称的,只写在一边;构建从两个方向检查它。同一位置上三条或更多互相冲突的规定是备选项,改为收进一个择一组。一条规定不得声明与这样的规定冲突:启用它时会沿着 parent 或 requires 直接或间接带进来的规定,因为启用前者就会启用后者;构建遇到这种声明会失败。同一个 parent 下的兄弟规定可以互相冲突。

构建检查下面这些约束:

  • 树里和依赖里都没有环。
  • 规定的 parent 是规定时,算作它的一项依赖:启用一条规定就要求启用它的 parent,parent 不在 requires 下重复写。parent 是择一组时,这项依赖穿过择一组,落到择一组自己的 parent 上:启用「Python 包管理器」下的 uv,仍然要求启用这个择一组上面的那条规定。择一组的某个子节点启用了,就算这个择一组启用了。
  • 没有多余的依赖:如果 A 依赖 B、B 依赖 C,A 就不再列出 C。
  • 跨到另一棵子树的依赖,应当指向一个择一组,而不是某条具体的规定。这类依赖的数量没有硬性上限;构建会对它们给出警告。
  • 在一份规定清单或一个路径分段里,一个择一组至多启用一个子节点,启用的规定之间没有冲突。

规定清单启用了一条规定、却没有启用它依赖的某条规定时,构建要么失败,要么补上缺的那条;选哪一种还是待决问题。这与 parent 算作依赖是两回事:后者只是免去在 requires 下写 parent,缺了 parent 和缺了别的依赖一样处理。

规定包:一个规定包就是一棵子树,以它的根命名,规定清单可以整个启用它。子树里有择一组时,启用这个规定包就要求规定清单为其中每个择一组指定一个备选项;否则构建失败。

从规定追溯到宪法

每条规定都在它现有的 authority 字段里引用它所细化的宪法条文 (规定)。这个关联双向使用:

  • 宪法的某条条文修改时,列出引用它的规定,并开一个任务,逐条检查它们是否仍然适用。
  • 在网站上,宪法每条条文下面列出引用它的规定,读者由此看到这条条文在实践中落实成了什么。

兼容的修订号。 每条规定声明自己兼容的宪法修订号范围。最低修订号一般都写。最高修订号只在确知这条规定与更高的修订号不兼容时才写,比如宪法废除了这条规定所依赖的内容;不预防性地写上限,Python 打包生态对依赖上限的经验就是如此。宪法的某一条文被修订时,上面列出的那些规定逐条判断:仍然适用的不动;不再适用的加上限,或者在改动影响了怎样才算合规时改写并开新 ID (ID 与修订号)。编译时,规定清单钉住的修订号必须落在每条已启用规定的范围内;否则构建失败,并指出是哪条规定、它的范围是什么。

检查

编写检查不用任何专门的语言,声明式的也不用。一小组内置的检查原语 (「这个文件存在」「这个 TOML 文件里这个键是这个值」) 会不断需要再加一种情况、再加「或」、再加「除非」,直到它自己成了一门没人读得顺的语言。检查就是代码,分两种,没有第三种。

我们的规定:Rust。 本仓规定的检查是一个 Rust crate,每条规定一个模块,模块指向这条规定的文件,就像 ruff 和 clippy 把每条 lint 实现为一个函数。常用的操作,例如从 TOML 文件读一个键,是同一个 crate 里普通的辅助函数;一个操作第三次重复时才抽成辅助函数,不提前 (simplicity)。这个 crate 构建成一个二进制文件,随规定一起发布,所以下游用户装一个二进制文件就有了全部检查,不用部署任何脚本。

一条规定被取代时,它的后继规定得到一个新函数,从旧函数复制过来,按需修改。旧函数留在代码里,标为被取代,除非专门要求,否则不运行。

下游编写的规定:外部命令。 存放在别的规定源里的规定,把它的检查声明为一条命令,用什么语言都可以。脚本放在那个规定源自己的仓库里,用它自己的工具链运行;规定写明它需要的工具 (例如 uv 或 Go)。约定如下:

  • 退出码 0:满足规定;1:违反规定;其他任何值:检查无法判定;
  • 标准输出:JSON,为每一处发现给出位置和原因。

允许规定没有自动检查;这样的规定直接交给审查方 (见下文)。

结果与审查回路

检查有三种结果:通过、违反和无法判定。缺工具、超时和脚本崩溃都算无法判定,从不算违反,所以缺了某个工具链不会让检查变红。

自动检查先运行,因为它们成本最低。然后审查方查看违反的结果、无法判定的结果,以及没有自动检查的规定。是由一个审查方全部接手,还是由几个审查方各接一组,以及怎样分组,在规定清单里设定。

对交给它的每条规定,审查方判断规定是满足还是违反。违反的要修正。审查方的判断与检查的结论不同时,就是检查错了:

  • 误报:检查标记了一处其实满足规定的内容;
  • 漏报:检查放过了一处违反规定的内容,是在审查别的结果或在后来的工作中发现的。

两种情况都会引出一项提案:修正这条规定的检查。修正检查不改变什么算合规,所以这条规定保留 ID,修订号加一 (wrong-checkers.fixing)。人和 agent 都可以依照 rule-evolution 提出修改规定。规定就是这样自我改进的:成本低的检查先跑,agent 只看检查定不了的部分,agent 得出的结论再回到检查里。

谁来判断。 编译、升级、判断冲突和兼容范围,凡是脚本能判定的,都由确定性的脚本完成。脚本判断不了的,交给 agent,走上面的三种结果和审查回路。这些判断不需要人参与;只有宪法本来就要求人批准的地方,人才出现。这与 automation-first.decide-and-do 一致。

试行规定

agent 做某项工作时,发现没有任何规定、skill 或惯例覆盖它,就开始把惯例写下来。第一个版本是试行规定:用在实际工作中,试行期间可以随意修改,定型之后成为正式规定。本仓自己的规定、skill 和惯例,也打算按同样的方式产生。

编译与网站

在本地,编译器读取宪法 (agent 版) 和一份规定清单及其锁文件,写出:

  • 一份 AGENTS.md,开头写明它依据的宪法的仓库、版和修订号,然后是选用的规定,沿着 parent 树放在嵌套的块里,树每深一层,标题就降一级;根没有启用的子树 (对择一组来说:它的子节点一个都没有启用) 不产生任何内容,因为没有它,其中的规定一条也启用不了;跨子树的依赖渲染成一个链接。文件长度超过一个 token 阈值 (用 count-tokens 统计) 之后,拆成几个文件;
  • 选用的规定需要的 skill 目录;
  • 要安装的检查二进制文件的版本,以及选用规定的外部命令;
  • 运行检查的钩子配置。

编译、检查和升级规定清单由同一个命令行工具完成。宪法的编译器暂时仍用 Python (编译),以后再并入那个工具。

公开网站列出每条没有被取代的规定,让人们能从完整列表里挑选。parent 树就是它的目录,每条规定的页面显示它的依赖。被取代的规定保留自己的页面,指向它的链接仍然有效,但默认不出现在列表里。

为什么用 Rust

速度不是主要原因。几百次文件检查和正则表达式匹配,用 Python 也只要一两秒;真正花时间和钱的是审查方调用模型。选 Rust 的原因是:

  • 检查作为一个二进制文件发布,不需要 Python 或别的运行时,所以用任何语言的下游仓库都能用;
  • 并行运行检查更容易写对;
  • limae 是一个以 Python wheel 发布、从 pre-commit 运行的 Rust linter,已经通过这种方式用到了下游仓库,所以这条分发途径已经试过。

它将取代什么

本设计实现之后,会取代当前设计的一些部分。那些部分现在不改;等实现本设计的工作合入时再改。

  • ID:规定 ID 改为按顺序分配的数字,不再用单词。rules/*.toml 里的规定迁移到新格式时获得编号,届时引用 {@rule:<id>} 写的就是数字。
  • 规定:规定不再删除,所以不再需要 rules/retired-ids.txt;规定改为被取代或废止。status 增加 trial 和 superseded,并增加字段 supersedes、每条规定的修订号、parent、requires、conflicts、默认层级 (仓库或用户) 和规定兼容的宪法修订号范围;择一组成为 parent 树上的一种节点。验证方法是每条规定正文的一部分,它的检查是代码 (见检查)。
  • 选用规定:带锁文件、在其中列出规定 (按 ID 或规定包) 的规定清单,取代「默认使用全部通用规定,再点名排除」。规定清单属于一个仓库,可以继承一份基础规定清单,由后者接管法域的那部分职能。每份规定清单还写明它依据的宪法 (见规定清单与锁文件)。
  • 放宽声明:规定清单不用某条规定,或者在择一组里选了另一个备选项,都不需要放宽。adjustable 的值和 relaxes: 声明是否保持原样,在实现本设计时决定。
  • 概览:「每个法域自己选用哪些通用规定」这项目标和私有 overlay 选用规定的职责,移到每个仓库的规定清单 (连同继承的基础清单),overlay 的 apply 步骤变成编译命令;overlay 保留与个人相关的部分和它自己的规定。
  • Skill:skill 声明自己落实的规定,只有其中某条被选用时才安装。
  • 编译:输出增加 skill 目录、检查和钩子配置,编译出的文件开头写明它依据的宪法的仓库、版和修订号,规定沿着 parent 树分组,而不是按第一个 authority 所在的条分组。
  • 检查程序:增加由 Rust 二进制文件运行的规定检查及其三种结果;本仓源文件现有的检查程序保留。

待决问题

  • 编译出的 AGENTS.md 达到多少 token 时拆成几个文件,沿哪些子树拆。
  • 锁文件的格式。
  • 公开规定源的索引:格式、谁可以往里加、什么时候构建。
  • 树顶的领域本身是一条规定,还是只是一个标题。
  • 包管理器模型的具体字段和命令 (规定清单与锁文件),在实现时确定;其中包括 tag 或分支、版本范围在规定清单里和在规定源声明所需版本时怎样写,规定清单怎样写明它依据的宪法,以及规定兼容的宪法修订号范围怎样写。
  • 默认层级为用户级的规定,在哪里选用、在哪里锁定、编译到哪里。
  • 规定清单启用了一条规定、却没有启用它 requires 的某条规定时,构建是失败还是补上缺的那条。
  • 规定及其检查的目录结构:规定的正文和元数据是继续放在 rules/ 下自己的文件里、由 crate 的模块指向它,还是搬到检查旁边。
  • 试行规定怎样成为正式规定,由谁决定。
  • 关于怎样写设计文档本身的规定和 skill,以后再写成规定和 skill。