diff --git a/.agents/README.md b/.agents/README.md index ac5ab6ec..40dd597f 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -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/.json` 为单源,校验各品类 skill 的内联副本名目一致 / 纯指针可达,漂移 exit 1;已挂 pre-commit + Gitea Actions | | `doc-organizer.{sh,-analyze.mjs}` + `doc-organizer-state.json` | doc-organizer 底层:检测(scan/health/stale/ledger)+ 可逆归档 + 记时 + 三轴分析 Workflow + 增量窗口锚点 | --- diff --git a/.agents/tools/rubric-sync-gate.py b/.agents/tools/rubric-sync-gate.py new file mode 100755 index 00000000..bb7ae222 --- /dev/null +++ b/.agents/tools/rubric-sync-gate.py @@ -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/.json` 为**单一数据源**。部分品类的 +设计 skill(`.agents/skills/-game-design.md`)出于人读需要,在 §10/§11 里 +留了一份内联条目副本;另一些已收敛成"只留一个指针指向 fixture、不再抄表"。两种形态 +都有漂移风险:内联副本会与 fixture 各改一侧而失同步,纯指针会指向一个被改名/删除的 +fixture。本门把这条"改一处必须同改另一处 / 指针必须真实可达"的纪律编译成可被 CI 与 +pre-commit 消费的退出码。 + +判据(逐品类): + 1. 通用底座:fixture 文件必须存在、能解析、items 非空且每条有 name —— 否则硬失败。 + 2. 指针完好(所有品类):skill 正文必须引用到本品类 fixture 路径 + `genre-rubrics/.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 + +# 本文件 = /.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)) diff --git a/.gitea/workflows/contract-gates.yml b/.gitea/workflows/contract-gates.yml new file mode 100644 index 00000000..cde0a82b --- /dev/null +++ b/.gitea/workflows/contract-gates.yml @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8ca4c168..515d5282 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/contracts/prompts/README.md b/contracts/prompts/README.md index da56585f..6dae8f8c 100644 --- a/contracts/prompts/README.md +++ b/contracts/prompts/README.md @@ -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 每次全量跑。 diff --git a/docs/architecture/架构/生成引擎/prompt治理.md b/docs/architecture/架构/生成引擎/prompt治理.md index 7d6c6924..9ddfe8da 100644 --- a/docs/architecture/架构/生成引擎/prompt治理.md +++ b/docs/architecture/架构/生成引擎/prompt治理.md @@ -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 是一个**叠加层**——它叠在原有调用方式之上,而不是替换掉: