在文档旁放 YAML 契约记录模型改动了哪些章节,reviewer 只审查模型的实际影响范围,而非全文。
生成式起草降低了文档生产的成本,但瓶颈转移到了验证环节——大多数文档评审仍然是通读全文,而不是聚焦于模型的改动范围。社区已经提出了一个共识:AI 让每个开发者都成了评审者,但没有人为主动评审环节本身建立基准。实际的解决方案不是更好的 prompt,而是一份生成清单(generation manifest)——它在写作时精确记录模型修改了哪些章节,这样人类只需对照这份清单审核 diff 部分。
当起草免费时,清单门禁就变得至关重要,因为免费的起草能力会膨胀评审者原本需要阅读的文本量。廉价的生成应该改变工作流程而不是工作量:模型写更多,人验证更少,而且"更少"有一个机器可校验的定义。Git 的作者元数据无法提供这个定义,因为 rebase、squash 和协作式的 commit 约定使得在 commit 级别以下的作者归属变得不可靠。
工作流从一个小型 YAML 契约开始,它放在文档旁边,为每个章节指定三类所有者之一:draft(模型起草)、human(人工自有)或 verify-with-example(以示例验证)。这份契约是模型和 CI 门禁的共同事实来源,它将"人类负责安全备注"这样的抽象策略转化为具体的映射。我们将其提交到代码库中,这样生成器和评审者读取的是同一个文件。
# docs/doc-contract.yaml
version: 1
sections:
quickstart:
owner: model-draft
source: examples/demo.py
verify: run
config-reference:
owner: human-own
source: null
verify: none
api-error-codes:
owner: verify-with-example
source: src/errors.py
verify: run
security-notes:
owner: human-own
source: null
verify: none
每个 owner 值以不同方式改变了生成器、CI 门禁和评审者的义务。下表总结了管道其余部分强制执行的契约语义。
章节所有权是一种策略,清单则是其可执行的形式。清单由生成草稿的同一过程写入,而不是事后从 git 历史中重建,因为只有生成器确切知道它编辑了哪些标题、读取了哪些源文件。这个设计将清单与风险评分区分开来:评分需要由阅读文档的人来计算,而清单在起草时就可以免费计算,因为它是在起草步骤中生成的。
本工作流的起草步骤建立在 MonkeyCode 的免费模型访问及其免费服务器选项之上;免费访问消除了重复起草的边际成本,免费服务器选项将生成循环保持在你可控制的环境中,而不是共享端点上。披露:本文是 MonkeyCode 产品推广的一部分。最终产物是一份提交的 JSON 文件,列出了每个生成的标题及其对应的喂给该章节的源的哈希值。
{
"generator": "draft-loop",
"created_at": "2026-08-29T10:00:00Z",
"sections": {
"quickstart": {
"source_sha256": "7f2a9c1b4e0d8a3f6c5e2b1d9a4c8f0e6b7d5a2c1f9e8b0d3c4a5f6e7d8b9c0a1f"
},
"api-error-codes": {
"source_sha256": "3d8b6f2a91c4e5d0b7a6f3c2e1d9b8a7c6f5e4d3b2a1c9f8e7d6b5a4c3f2e1d0b9"
}
}
}
将 generator 字段设为你使用的精确工具和版本,因为清单同时也是文档管道的审计日志。关键细节是时机:清单与草稿在同一环节写入,因此不存在事后猜测"什么改变了"的问题。
执行层是一个单一的 Python 脚本,包含三个子命令:gate、diff 和 verify。gate 拒绝包含人工自有章节的清单;diff 比较文档的两个版本,当人工自有章节发生变化时失败;verify 步骤根据清单中的源哈希值对照当前文件进行检查。
#!/usr/bin/env python3
"""docbound.py — gate model-drafted docs against an ownership contract."""
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
import yaml
CONTRACT_PATH = "docs/doc-contract.yaml"
MANIFEST_PATH = "docs/gen-manifest.json"
def extract_sections(markdown_text: str) -> dict:
"""Map each h2-h4 heading to the raw text of its section."""
lines = markdown_text.splitlines()
headings = [i for i, line in enumerate(lines) if re.match(r"^#{2,4}\s+", line)]
sections = {}
for idx, start in enumerate(headings):
end = headings[idx + 1] if idx + 1 < len(headings) else len(lines)
title = re.sub(r"^#{2,4}\s+", "", lines[start]).strip()
sections[title] = "\n".join(lines[start:end])
return sections
def changed_sections(base_md: str, changed_md: str) -> list:
base = extract_sections(base_md)
changed = extract_sections(changed_md)
touched = [t for t in base if t not in changed or base[t] != changed[t]]
touched += [t for t in changed if t not in base]
return touched
def load_contract(path: str) -> dict:
return yaml.safe_load(Path(path).read_text())
def load_manifest(path: str) -> dict:
return json.loads(Path(path).read_text())
def cmd_gate(args) -> None:
contract = load_contract(args.contract)
manifest = load_manifest(args.manifest)
errors = []
for heading in manifest["sections"]:
if heading not in contract["sections"]:
errors.append(f"{heading}: missing from contract")
elif contract["sections"][heading]["owner"] == "human-own":
errors.append(f"{heading}: human-owned section in model manifest")
if errors:
print("gate failed")
for err in errors:
print(" -", err)
sys.exit(1)
print("gate passed: manifest touches no human-owned sections")
def cmd_diff(args) -> None:
contract = load_contract(args.contract)
touched = changed_sections(
Path(args.base).read_text(), Path(args.changed).read_text()
)
issues = [
t for t in touched
if t in contract["sections"]
and contract["sections"][t]["owner"] == "human-own"
]
if issues:
print("human review required on:")
for heading in issues:
print(" -", heading)
sys.exit(1)
print("diff clean:", ", ".join(touched) if touched else "no sections changed")
def cmd_verify(args) -> None:
contract = load_contract(args.contract)
manifest = load_manifest(args.manifest)
for heading, meta in manifest["sections"].items():
entry = contract["sections"].get(heading)
if not entry or entry.get("verify") != "run":
continue
source = Path(entry["source"])
actual = hashlib.sha256(source.read_bytes()).hexdigest()
if actual != meta.get("source_sha256"):
print(f"{heading}: source drifted since the draft was generated")
sys.exit(1)
print("verify passed: example sources match the generation inputs")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
subs = parser.add_subparsers(dest="command", required=True)
gate = subs.add_parser(
"gate", help="reject manifests that touch human-owned sections"
)
gate.add_argument("--contract", default=CONTRACT_PATH)
gate.add_argument("--manifest", default=MANIFEST_PATH)
gate.set_defaults(func=cmd_gate)
diff = subs.add_parser(
"diff", help="list and gate sections changed between two doc versions"
)
diff.add_argument("--base", required=True)
diff.add_argument("--changed", required=True)
diff.add_argument("--contract", default=CONTRACT_PATH)
diff.set_defaults(func=cmd_diff)
verify = subs.add_parser(
"verify", help="confirm example sources have not drifted"
)
verify.add_argument("--contract", default=CONTRACT_PATH)
verify.add_argument("--manifest", default=MANIFEST_PATH)
verify.set_defaults(func=cmd_verify)
subs.add_parser("main", help=argparse.SUPPRESS).set_defaults(func=lambda a: parser.print_help())
parser.parse_args()
if __name__ == "__main__":
main()
在开 PR 之前按 CI 相同的顺序本地运行这三个检查。diff 命令需要文档的基准版本和变更版本,可以配合任意 git checkout 使用。
python scripts/docbound.py gate
python scripts/docbound.py diff --base /tmp/docs-base.md --changed docs/index.md
python scripts/docbound.py verify
对于 GitHub Actions,以下工作流是一个起点,从目标分支获取基准文档。它只在文档或门禁脚本本身发生变化时运行,因此在无关的 PR 上不产生任何成本。
name: docs-gate
on:
pull_request:
paths: ["docs/**", "scripts/docbound.py"]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python scripts/docbound.py gate
- run: |
git show origin/main:docs/index.md > /tmp/docs-base.md
python scripts/docbound.py diff --base /tmp/docs-base.md --changed docs/index.md
- run: python scripts/docbound.py verify
清单门禁改变了评审会议的形态,因为评审者不再从头打开文档;他们打开一份简短的已修改标题列表和解释这些标题为何发生变化的清单。预计门禁在典型的文档 PR 上会输出两到三个标题,并将慢速阅读留给确实出现在该列表中的人工自有章节。这个缩减是可量化的结果,这也是为什么清单应该放在代码库中而不是对话摘要里的原因。
门禁信任清单作为诚实记录,因此如果生成器编辑了声明输出之外的文件,或者队友将模型生成的文本粘贴到人工自有的 PR 中,就绕过了所有检查;人工自有路径上的代码所有者是补偿性控制。基于标题的章节提取在重复标题和高度碎片化的表格上会失效,因此章节需要稳定的锚点 slug 才能进行可靠的 diff。门禁缩窄了人类需要阅读的内容,但无法判断草稿级 prose 是否正确,因为正确性仍然来自文档之外的可执行示例和测试。没有为人工章节指定命名所有者的团队根本不应该启用免费层起草,因为无主 prose 的积累速度会超过评审能力,对于稳定产品的两页 README 来说也不值得承担契约的开销。
如果你的文档已经有了所有权契约,添加清单和 docbound 门禁,然后度量一个数字:评审者在接下来十个文档 PR 中实际打开了多少个章节。如果这个数字保持平稳,说明契约从未被足够严格地执行过。如果它下降到少数几个标题,免费起草就变成了一项资产而非负担,因为评审循环终于能够随 diff 规模而扩展,而不是随文档规模而扩展。