merge: B8 门挂载——四契约门挂 pre-commit(.githooks 五门条件触发)+ Gitea Actions contract-gates.yml + rubric↔skill 双写对账门(rubric-sync-gate.py)+ check_registry 真挂载 + prompt治理档口径对齐(Δ5 执行面)·负向演示×4 全红转绿·live-fire 实证
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-03 14:04:24 -07:00
commit 685a21a74c
6 changed files with 340 additions and 14 deletions

View File

@ -81,6 +81,7 @@
|---|---|
| `docs-gate.py` / `docs-gate.sh` | 文档治理机器门(engineering-conventions §10.7):七检——品牌不变量 / canonical 唯一 / 死链 / 入口卫生 / 留痕隔离 / 设计档申报 / 计划谱系(`上级:` 单指针) |
| `plan-tree.py` | 计划谱系树查询视图(engineering-conventions §10.8):沿 frontmatter `上级:` 拼树打印,回答「某计划在上线计划的哪个位置 / 某线推进到哪」 |
| `rubric-sync-gate.py` | 品类 rubric fixture ↔ skill 双写对账门(Δ5「无校验器不算契约」执行面):以 `cheap-worker/fixtures/genre-rubrics/<genre>.json` 为单源,校验各品类 skill 的内联副本名目一致 / 纯指针可达,漂移 exit 1;已挂 pre-commit + Gitea Actions |
| `doc-organizer.{sh,-analyze.mjs}` + `doc-organizer-state.json` | doc-organizer 底层:检测(scan/health/stale/ledger)+ 可逆归档 + 记时 + 三轴分析 Workflow + 增量窗口锚点 |
---

259
.agents/tools/rubric-sync-gate.py Executable file
View File

@ -0,0 +1,259 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""rubric-sync-gate.py —— 品类 rubric fixture ↔ 品类 skill 双写对账机器门。
背景:每个品类的丰富度 rubric(喂 LLM 验证 agent 的评分尺)以
`cheap-worker/fixtures/genre-rubrics/<genre>.json` 为**单一数据源**。部分品类的
设计 skill(`.agents/skills/<genre>-game-design.md`)出于人读需要,在 §10/§11 里
留了一份内联条目副本;另一些已收敛成"只留一个指针指向 fixture、不再抄表"。两种形态
都有漂移风险:内联副本会与 fixture 各改一侧而失同步,纯指针会指向一个被改名/删除的
fixture。本门把这条"改一处必须同改另一处 / 指针必须真实可达"的纪律编译成可被 CI 与
pre-commit 消费的退出码。
判据(逐品类):
1. 通用底座:fixture 文件必须存在、能解析、items 非空且每条有 name —— 否则硬失败。
2. 指针完好(所有品类):skill 正文必须引用到本品类 fixture 路径
`genre-rubrics/<genre>.json`,且该文件真实存在 —— 否则硬失败。
3. 内联对账(仅带内联条目副本的品类):
逐条按序比对 fixture 的 name 与 skill 内联表/清单抽出的条目名。
· 规范化后逐字相等 → 一致;
· 规范化后 fixture 名是 skill 名的子序列 → 判为"同条目·措辞装饰"(容忍,
打印提示不失败)——skill 是人读自检清单,允许在 canonical 名上加描述性修饰
(如"风险回报取舍"写成"风险-回报取舍真实");
· 否则 → 判为真实漂移(改名/替换),硬失败并打印差异;
条目数不一致(增删条目)→ 硬失败。
规范化 = 只保留中日韩统一表意文字(去标点/空格/ASCII/加减号等),以吸收人读排版差异、
只在"名目"层面对账。
零依赖:仅标准库(json/re/sys/pathlib),与同仓 docs-gate.py / check_registry.py /
play-loop/validate.py 同栈同风格。
用法:
python3 .agents/tools/rubric-sync-gate.py # 对本仓
python3 .agents/tools/rubric-sync-gate.py --root DIR # 对指定仓根(供负向演示跑副本)
# exit 0 = 全部对齐;exit 1 = 有真实漂移/指针断裂/fixture 损坏
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
# 本文件 = <root>/.agents/tools/rubric-sync-gate.py → 仓根上溯两级
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
# 品类配置(逐一考察真实状态后编码,2026-07-03):
# mode=inline → skill 在指定小节留了内联条目表/清单,需逐条对账;
# mode=pointer → skill 已收敛为纯指针,只校验指针路径真实存在。
# section_anchor:内联小节标题前缀(用于把抽取范围钉在该节内,不误采别处的表/清单)。
# row_re:该节内"条目行"的识别正则(表行 / 编号清单行)。
GENRES = {
"puzzle": {
"skill": ".agents/skills/puzzle-game-design.md",
"mode": "inline",
"section_anchor": "## 10.",
"row_re": r"^\|\s*P\d", # §10 品类扩展表:| P1 | L2 | 谜题规则递进 | …
},
"trpg": {
"skill": ".agents/skills/trpg-game-design.md",
"mode": "inline",
"section_anchor": "## 10.",
"row_re": r"^\d+\.\s+\*\*", # §10 自检编号清单:1. **[L2] 掷骰过程可见**:…
},
"heritage": {
"skill": ".agents/skills/heritage-game-design.md",
"mode": "inline",
"section_anchor": "## 11.",
"row_re": r"^\|\s*H\d", # §11 品类 rubric 表:| H1 | L2 | **工序链成立**:… |
},
"narrative": {
"skill": ".agents/skills/narrative-game-design.md",
"mode": "pointer", # §10 已迁出为单源指针,不再维护表格副本
},
"sim-business": {
"skill": ".agents/skills/sim-business-game-design.md",
"mode": "pointer", # §10 只有通用自检 + 指向 fixture 的指针,无品类表副本
},
}
_IDEOGRAPH = re.compile(r"[一-鿿]")
def _norm(name: str) -> str:
"""规范化:只留中日韩表意文字,吸收标点/空格/ASCII/加减号等人读排版差异。"""
return "".join(_IDEOGRAPH.findall(name or ""))
def _is_subsequence(needle: str, haystack: str) -> bool:
"""needle 的字符是否按序(可不连续)出现在 haystack 中。"""
it = iter(haystack)
return all(ch in it for ch in needle)
def _fixture_path(root: Path, genre: str) -> Path:
return root / "cheap-worker" / "fixtures" / "genre-rubrics" / f"{genre}.json"
def _load_fixture_names(root: Path, genre: str):
"""读 fixture,返回 (names 列表, 错误串)。错误串非空表示通用底座判据未过。"""
fp = _fixture_path(root, genre)
if not fp.exists():
return None, f"fixture 缺失:{fp}"
try:
data = json.loads(fp.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 —— 解析失败即 fixture 损坏,如实报
return None, f"fixture JSON 解析失败:{e}"
items = data.get("items")
if not isinstance(items, list) or not items:
return None, "fixture 缺 items 或为空"
names = []
for i, it in enumerate(items):
nm = (it or {}).get("name")
if not nm:
return None, f"fixture items[{i}] 缺 name"
names.append(nm)
return names, None
def _slice_section(lines, anchor: str):
"""取 anchor 小节的行区间:从标题行到下一条 `---` 或下一个 `## ` 标题之前。"""
start = None
for i, ln in enumerate(lines):
if ln.startswith(anchor):
start = i
break
if start is None:
return None
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j].strip()
if s == "---" or lines[j].startswith("## "):
end = j
break
return lines[start:end]
def _extract_inline_name(row: str) -> str:
"""从一条内联条目行抽出品类条目名。统一处理三种形态:
· 表行(含 `|`):取第 3 个单元格(条目/判据列);
· 编号清单行:取 `N. ` 之后的正文;
再:剥 markdown 粗体 `**`、去开头的 `[Lx]` 层标、截到首个中文冒号/英文冒号前。
"""
if "|" in row:
cells = row.split("|")
frag = cells[3] if len(cells) > 3 else row
else:
frag = re.sub(r"^\d+\.\s+", "", row)
frag = frag.replace("**", "")
frag = re.sub(r"^\s*\[L\d\]\s*", "", frag) # 去 trpg 的 [L2] 层标前缀
frag = re.split(r"[::]", frag, maxsplit=1)[0]
return frag.strip()
def _skill_inline_names(root: Path, cfg: dict):
"""按配置从 skill 指定小节抽出内联条目名列表,返回 (names, 错误串)。"""
sp = root / cfg["skill"]
if not sp.exists():
return None, f"skill 缺失:{sp}"
lines = sp.read_text(encoding="utf-8").splitlines()
section = _slice_section(lines, cfg["section_anchor"])
if section is None:
return None, f"skill 找不到小节 {cfg['section_anchor']}"
row_re = re.compile(cfg["row_re"])
names = [_extract_inline_name(ln) for ln in section if row_re.match(ln)]
return names, None
def _skill_has_pointer(root: Path, genre: str, cfg: dict):
"""校验 skill 正文含指向本品类 fixture 的指针,返回 (bool, 错误串)。"""
sp = root / cfg["skill"]
if not sp.exists():
return False, f"skill 缺失:{sp}"
text = sp.read_text(encoding="utf-8")
if f"genre-rubrics/{genre}.json" not in text:
return False, f"skill 未引用 fixture 指针 genre-rubrics/{genre}.json"
return True, None
def check_genre(root: Path, genre: str, cfg: dict):
"""对账单个品类,返回 (ok, 明细行列表)。ok=False 表示硬失败。"""
detail = []
# 判据 1:通用底座 —— fixture 完好
fx_names, err = _load_fixture_names(root, genre)
if err:
return False, [f" ✗ {err}"]
# 判据 2:指针完好(所有品类)
ptr_ok, ptr_err = _skill_has_pointer(root, genre, cfg)
if not ptr_ok:
return False, [f" ✗ {ptr_err}"]
detail.append(f" ✓ 指针可达:skill 引用 genre-rubrics/{genre}.json,fixture {len(fx_names)} 条")
if cfg["mode"] == "pointer":
detail.append(" · 形态=纯指针(skill 不维护副本),只校验指针 —— 通过")
return True, detail
# 判据 3:内联对账
sk_names, err = _skill_inline_names(root, cfg)
if err:
return False, detail + [f" ✗ {err}"]
if len(sk_names) != len(fx_names):
return False, detail + [
f" ✗ 条目数漂移:fixture {len(fx_names)} 条 vs skill 内联 {len(sk_names)} 条",
f" fixture:{fx_names}",
f" skill :{sk_names}",
]
drift = []
paraphrase = []
for i, (fn, sn) in enumerate(zip(fx_names, sk_names)):
fnn, snn = _norm(fn), _norm(sn)
if fnn == snn:
continue
if _is_subsequence(fnn, snn):
paraphrase.append(f" · 第{i + 1}条 同条目·措辞装饰:fixture「{fn}」→ skill「{sn}」")
else:
drift.append(f" · 第{i + 1}条 真实漂移:fixture「{fn}」 ≠ skill「{sn}」")
if drift:
return False, detail + [f" ✗ 内联副本与 fixture 名目漂移({len(drift)} 条):"] + drift + paraphrase
if paraphrase:
detail.append(f" ✓ 内联副本 {len(fx_names)} 条名目一致({len(paraphrase)} 条为措辞装饰、非漂移):")
detail.extend(paraphrase)
else:
detail.append(f" ✓ 内联副本 {len(fx_names)} 条名目逐字一致")
return True, detail
def main(argv) -> int:
root = DEFAULT_ROOT
if len(argv) >= 3 and argv[1] == "--root":
root = Path(argv[2]).resolve()
print("品类 rubric fixture ↔ skill 双写对账门")
print("=" * 60)
all_ok = True
for genre, cfg in GENRES.items():
ok, detail = check_genre(root, genre, cfg)
status = "PASS" if ok else "FAIL"
icon = "✔" if ok else "✗"
print(f"\n[{status}] {genre} {icon} ({cfg['mode']})")
for d in detail:
print(d)
if not ok:
all_ok = False
print("\n" + "=" * 60)
if all_ok:
print("对账通过:所有品类 fixture 单源与 skill 副本/指针一致。")
return 0
print("对账失败:见上 ✗ —— 改一处必须同改另一处(fixture 为单源),或修复断裂指针。")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -0,0 +1,35 @@
# 契约机器门 · 服务端兜底(Gitea Actions,与 GitHub Actions 语法兼容)
# pre-commit 是自愿门(--no-verify / 新 clone 未配 hooksPath 可绕),本工作流是不可绕的服务端门。
# 与 docs-gate.yml 平级、各管一摊:docs-gate 管文档治理七检,本工作流管四类契约门。
# 生效前提:Gitea 实例已配 act_runner;未配时本文件静默不跑,不影响开发。
# CI 侧全量真跑(不做 pre-commit 的路径条件触发):判例库四段尽量装齐 esbuild + pytest 后全跑,
# 装不齐时 run.mjs 自带零依赖降级、诚实 SKIP 缺依赖段,不假绿。
name: contract-gates
on:
push:
branches: ['**']
pull_request:
jobs:
contract-gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。
# 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。
- name: 预装门依赖(尽力)
run: |
pip3 install --quiet pytest || true
npm ci --prefix game-runtime/tools/amodel-gen || true
- name: 门金标判例库全量回归
run: node contracts/gate-fixtures/run.mjs
- name: play-loop 契约正负样本套件
run: python3 contracts/play-loop/validate.py --suite
- name: 品类 rubric fixture↔skill 双写对账
run: python3 .agents/tools/rubric-sync-gate.py
- name: Prompt Registry 版本一致性
run: python3 contracts/prompts/check_registry.py

View File

@ -1,9 +1,40 @@
#!/usr/bin/env bash
# 文档治理门 pre-commit(激活一次:git config core.hooksPath .githooks)
# 仅当提交含 md / AGENTS.md 变更时跑 docs-gate(<5s);紧急绕过:git commit --no-verify(服务端与 wave-close 门仍会兜住)。
if git diff --cached --name-only | grep -qE '\.md$'; then
bash "$(git rev-parse --show-toplevel)/.agents/tools/docs-gate.sh" || {
echo '✘ docs-gate 未过,提交被拦(明细见上;紧急绕过 --no-verify)'
exit 1
}
# 治理机器门 pre-commit(激活一次:git config core.hooksPath .githooks)
# 各门按暂存文件路径条件触发(判据没动的提交不跑对应门,省时间);
# 紧急绕过:git commit --no-verify(服务端 Gitea Actions 门仍会兜住)。
set -uo pipefail
STAGED="$(git diff --cached --name-only)"
ROOT="$(git rev-parse --show-toplevel)"
fail=0
# ── 门 1:文档治理七检 —— 触发 = 任一 .md 变更 ──
if printf '%s\n' "$STAGED" | grep -qE '\.md$'; then
bash "$ROOT/.agents/tools/docs-gate.sh" || { echo '✘ docs-gate 七检未过'; fail=1; }
fi
# ── 门 2:门金标判例库全量回归 —— 触发 = 触及任一验收门判据或判例库自身 ──
# 判据文件改动前必须先让本回归全绿(误杀/漏放判例不得回退),口径见 contracts/gate-fixtures/README.md。
if printf '%s\n' "$STAGED" | grep -qE '(play\.cdp\.cjs|amodel-gen/tools\.mjs|check-undef\.mjs|gate_judge\.py|cheap_gates\.py|^contracts/gate-fixtures/)'; then
node "$ROOT/contracts/gate-fixtures/run.mjs" || { echo '✘ 门金标判例库回归未过'; fail=1; }
fi
# ── 门 3:play-loop 契约校验(PlaySpec C5 / VerdictFeedback C6)—— 触发 = 触及 play-loop 契约 ──
if printf '%s\n' "$STAGED" | grep -qE '^contracts/play-loop/'; then
python3 "$ROOT/contracts/play-loop/validate.py" --suite || { echo '✘ play-loop 契约正负样本套件未过'; fail=1; }
fi
# ── 门 4:品类 rubric fixture ↔ skill 双写对账 —— 触发 = 触及品类 rubric fixture 或品类 skill ──
if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.agents/skills/(trpg|heritage|puzzle|narrative|sim-business)-game-design\.md)'; then
python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; }
fi
# ── 门 5:Prompt Registry 版本一致性 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ──
if printf '%s\n' "$STAGED" | grep -qE '^contracts/prompts/'; then
python3 "$ROOT/contracts/prompts/check_registry.py" || { echo '✘ Prompt Registry 版本一致性未过'; fail=1; }
fi
if [ "$fail" -ne 0 ]; then
echo '提交被拦(明细见上;紧急绕过 git commit --no-verify,服务端门仍兜)'
exit 1
fi

View File

@ -32,8 +32,8 @@ python3 contracts/prompts/check_registry.py
# exit 0 = 全部对齐;exit 1 = 有漂移(打印具体 id + 两侧 version)
```
**接入 CI / pre-commit(推荐):**
- CI:在流水线加步骤 `python3 contracts/prompts/check_registry.py`(无三方依赖,标准库即可跑)。
- pre-commit:在 `.pre-commit-config.yaml` 加 hook,`entry: python3 contracts/prompts/check_registry.py`,`pass_filenames: false`。
**已挂载(2026-07-03):**
- pre-commit:`.githooks/pre-commit` 在本次暂存触及 `contracts/prompts/` 时跑本脚本,非零即拦(紧急绕过 `git commit --no-verify`,服务端门仍兜)。
- CI:`.gitea/workflows/contract-gates.yml`(与 docs-gate 平级的服务端门)每次 push / PR 全量跑,不可绕。
触发时机:任何 `.md` 或 `registry.yaml` 变更都应跑一次;CI 全量跑。
触发时机:任何 prompt `.md` 或 `registry.yaml` 变更(pre-commit 按路径条件触发);CI 每次全量跑。

View File

@ -133,7 +133,7 @@ guardrails: [injection-detect, schema-validate] # 护栏:注入检测 / schema
frontmatter 里几个字段值得点名:**owner**(归属工位)规定了"谁能改这条 prompt"——本项目把工位编号为 WS1~WS5(WorkStation,工位),改一条 prompt 要经它的 owner 批准,杜绝无主乱改;**tier**(梯队)区分 prompt 的重要性,tier1 是 MVP 生产主线必须治理的,tier2/3 是更长期的增强轨;**constraints**(硬约束块)是产物必须守住的红线(如包体大小、零网络请求),**guardrails**(护栏)是运行时挂的自动检查(如注入检测、schema 校验)。**engine**(目标引擎)在设计期是区分注入载体的必填枚举,但在现行**单引擎**(new-api / SAA)语境下已退化为假设占位值(真实文件里写作 `engine: newapi-chat` 并自注"枚举未定"),不再是有效的区分维度——保留它只为与真实 frontmatter 一致。另注:示例里的 `input_schema` / `output_schema` 指向真实契约文件(`../../agent-loop/game-design.schema.json` 等),因为 §3 目录里那个 `_schemas/` 目录尚未落地(见上文目录树标注)。
`registry.yaml` 是这棵树的总索引,逐条登记每个 prompt 的 id、版本、owner、绑定的 schema 和 eval 目录。它和文件系统的实际内容必须**严格一致**——这份索引↔文件的对账由 §4 四道闸 CI 负责,在合入前挡下"配置漂移"(加载器自身只逐模板自检自禁用、不复制此对账逻辑,见 §5/§7)。
`registry.yaml` 是这棵树的总索引,逐条登记每个 prompt 的 id、版本、owner、绑定的 schema 和 eval 目录。它和文件系统的实际内容必须**严格一致**——这份索引↔文件的 version 对账由 `contracts/prompts/check_registry.py` 负责(2026-07-03 已挂 `.githooks/pre-commit` 与 Gitea Actions 服务端门,零依赖、退出码可拦提交;它与 §4 四道闸质量闸各管一摊、互不等待——四道闸尚在接线,version 对账已先行上门),在合入前挡下"配置漂移"(加载器自身只逐模板自检自禁用、不复制此对账逻辑,见 §5/§7)。
---
@ -189,7 +189,7 @@ boolean validate(PromptTemplate) # 校验 frontmatter 与 sche
**热取分 read / write 两路(§6.8 双评审修正,2026-06-24)**:运行时热取只解决"新版怎么到达运行时",**不等于绕过治理**。read path——运行期只拉**已批准版本**(按 production label/version),缓存+回落保证可用;write path——改一条 prompt、调一个生成门阈值,仍走四道闸的 version bump + eval + 灰度 + 人工批准(GitOps 审计留痕),**不 DB 热改、不绕审批**。只有运营降级开关/配额数值这类不影响生成质量与安全的配置才 DB 即时热改。坏版本经 label 秒回退,并记录其生效期间产出的 game_id 以便追溯。这条把 §1"prompt 是影响安全的第 8 契约"与"热取便利"调和:便利在 read,纪律在 write。
> 这里不存在"数据库只读镜像",也没有"运行时回源 git checkout":那是早期设计期契约里设想的载体,工程落地改走了更简单的构建期快照路。`registry.yaml` 与文件是否一致的对账是 §4 四道闸 CI 的职责,加载器不复制治理逻辑。
> 这里不存在"数据库只读镜像",也没有"运行时回源 git checkout":那是早期设计期契约里设想的载体,工程落地改走了更简单的构建期快照路。`registry.yaml` 与文件的 version 对账由 `check_registry.py` 承担(2026-07-03 已挂 pre-commit + Gitea Actions),加载器不复制治理逻辑。
第二组契约值得专门点名,因为它把"prompt 治理"和"产物质量"接到了一起——这就是 **T-AGC-09 测试脚本原子**(原子 = 生成流水线里一个不可再分的处理单元;T-AGC-09 是它的工序编号)。**注意它当前是设计/规划态契约,尚未实现**(game-cloud 内暂无对应实现);其规划接口形态如下:
@ -225,7 +225,7 @@ HITL 在整条链路里的位置很明确:**它永远站在机器闸之后**。
| frontmatter / schema 形态非法 | 加载器启动逐模板自检不过 → 整体 `ready=false` **自禁用**(执行器不带病认领任务),并由 CI 报出具体字段;**不抛异常、不崩进程** |
| eval 跑不通(模型限流 / 超时) | CI 标记 skip + 通知人工兜底审,**不阻塞紧急修复** |
| 改 prompt 未升 version | CI 卡死:version 未变 → 拒绝合入(即 §4 的闸 0) |
| registry.yaml 与文件不一致 | 由 CI 四道闸对账拦截(加载器不复制此治理逻辑,见 §5);合入前挡下漂移 |
| registry.yaml 与文件 version 不一致 | 由 `check_registry.py` 对账拦截(2026-07-03 已挂 pre-commit + Gitea Actions;加载器不复制此治理逻辑,见 §5);合入前挡下漂移 |
回滚同样是分层设计的,**整体回滚成本极低**,因为 Registry 是一个**叠加层**——它叠在原有调用方式之上,而不是替换掉: