From 6d760a7f513bfdb83f062c1b94a6d9230ff305ed Mon Sep 17 00:00:00 2001 From: randypanding Date: Sat, 29 Aug 2026 08:02:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A2=E6=AC=A1=20schema=20v1?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8D=A1=E6=A8=A1=E6=9D=BF=20budget/capabili?= =?UTF-8?q?ties/evidence=20+=20=E8=A7=A3=E6=9E=90=E5=99=A8=EF=BC=88IR-0006?= =?UTF-8?q?=20W2-C3=20=E5=89=8D=E5=8D=8A=20/=20IFACE-03=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/ISSUE_TEMPLATE/card.yml:卡模板(IFACE-03)——四必填字段 + budget(四元组+on_exceed)/capabilities(org-secret|vault 两形态 allowlist)/ evidence 三可选块;非法块=T7 拒绝就绪(fail-closed)说明 - governance/wave_schema.py:块解析(h2 手写/h3 表单双形态)+ 校验 (数值词表执法)+ wave-meta 物化(on_exceed 缺省 hard-stop)+ wave-check (统一账本按 subject.card 聚合、tenant 归因分离、超限 exit 4) - test-wave-schema.sh:正向双形态/缺块缺省/负向 8 项/wave-check 聚合断言 --- .github/ISSUE_TEMPLATE/card.yml | 72 ++++++++ governance/tests/test-wave-schema.sh | 146 ++++++++++++++++ governance/wave_schema.py | 239 +++++++++++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/card.yml create mode 100644 governance/tests/test-wave-schema.sh create mode 100644 governance/wave_schema.py diff --git a/.github/ISSUE_TEMPLATE/card.yml b/.github/ISSUE_TEMPLATE/card.yml new file mode 100644 index 0000000..3b52d00 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/card.yml @@ -0,0 +1,72 @@ +name: 工作卡(wave) +description: 波次工作卡——wave-planner 产卡用(IR-0006 W2-C3 / IFACE-03:budget/capabilities/evidence 可选块) +labels: [type:card] +body: + - type: markdown + attributes: + value: | + 卡正文是机器解析契约(conductor T7 解析 / cost-check 波次视图消费)—— + 标题与块名不可改写。budget/capabilities/evidence 三块可选:不写=无预算 + 约束(缺省语义);写了则必须合法(非法=T7 拒绝就绪,fail-closed)。 + 预算硬停语义:on_exceed: hard-stop 超限置熔断+撤 auto-merge+开 P0 + (ADR-0040 复位流程不变);warn 只告警。 + - type: textarea + id: header + attributes: + label: 卡头(父意图/Spec/波次/依赖) + description: 单行引用块——父意图: #n | Spec: specs/IR-NNNN/spec.md vN | 波次: W2 | 依赖: … + placeholder: | + > 父意图: #402 | Spec: `specs/IR-0006/spec.md` v1 | 波次: W2 | 依赖: W1 退出判据 + validations: + required: true + - type: textarea + id: task + attributes: + label: 任务 + description: 一段话说清本卡要交付什么 + validations: + required: true + - type: textarea + id: ac + attributes: + label: AC(Given-When-Then) + description: 逐条 id + given/when/then——验收谓词的锚点 + validations: + required: true + - type: textarea + id: blast + attributes: + label: blastRadius(预测) + description: 预计触碰面(仓:路径 列表) + validations: + required: true + - type: textarea + id: budget + attributes: + label: budget(波次预算) + description: >- + 四元组+on_exceed(YAML)。至少声明一项;on_exceed 缺省 hard-stop。 + cost-check 按统一账本 subject 聚合对账(BEH-07);human_minutes 暂无账本源(只报告)。 + placeholder: | + usd: 5.0 + tokens: 200000 + wallclock_sec: 7200 + human_minutes: 120 + on_exceed: hard-stop + - type: textarea + id: capabilities + attributes: + label: capabilities(能力 allowlist) + description: >- + allowlist 式引用,仅两形态:org-secret:<大写名>(org secret)或 + vault:<路径>(内网域 Vault——值永不进 Git)。 + placeholder: | + - org-secret:CNB_POOL_KEY + - vault:secret/wave/w2c1 + - type: textarea + id: evidence + attributes: + label: evidence(证据要求) + description: 本卡收口必须在本卡生命周期内落进统一账本的 action 列表 + placeholder: | + - gate.merge-verdict diff --git a/governance/tests/test-wave-schema.sh b/governance/tests/test-wave-schema.sh new file mode 100644 index 0000000..6264e2e --- /dev/null +++ b/governance/tests/test-wave-schema.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# test-wave-schema.sh —— 波次 schema v1 解析/校验/wave-check 自测(IR-0006 W2-C3 / 卡 #414) +# +# 覆盖(卡 AC 对应): +# parse:h2/h3(issue 表单产 h3)双形态块提取;缺块=空对象(缺省语义) +# validate:四元组数值/on_exceed 词表/capabilities 两形态 allowlist/evidence 非空 +# 负向:非法键/负值/坏 on_exceed/裸 secret 名 → exit 3(fail-closed) +# wave-check:按 subject.card 聚合(tenant 归因分离);超限 exit 4; +# 非法 budget 块=行级 error 不炸整批;无预算卡=跳过 +# 用法: bash governance/tests/test-wave-schema.sh(gate.yml 自动纳入) +set -uo pipefail +DIR="$(cd "$(dirname "$0")/../.." && pwd)" +WS="$DIR/governance/wave_schema.py" +FAILS=0 +pass() { echo "PASS $1"; } +fail() { echo "FAIL $1"; FAILS=$((FAILS+1)); } + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +# ---- 正向:h2 手写卡形态 ---- +cat >"$TMP/card-h2.md" <<'EOF' +> 父意图: #402 | Spec: `specs/IR-0006/spec.md` v1 | 波次: W2 | 依赖: W1 + +## 任务 +调度器 v0。 + +## budget(波次预算) +usd: 5.0 +tokens: 200000 +wallclock_sec: 7200 +human_minutes: 120 +on_exceed: hard-stop + +## capabilities(能力 allowlist) +- org-secret:CNB_POOL_KEY +- vault:secret/wave/w2c1 + +## evidence(证据要求) +- gate.merge-verdict +EOF +python3 "$WS" validate --body-file "$TMP/card-h2.md" >/dev/null \ + && pass "validate h2 形态绿(三块齐全)" || fail "validate h2 形态" +OUT=$(python3 "$WS" parse --body-file "$TMP/card-h2.md" --card "Cloudbird-Software/.github#414") +python3 - "$OUT" <<'PYEOF' && pass "parse h2:wave-meta 三块齐全+card 回填" || fail "parse h2 断言" +import json, sys +m = json.loads(sys.argv[1]) +assert m["card"] == "Cloudbird-Software/.github#414", m +assert m["budget"]["usd"] == 5.0 and m["budget"]["on_exceed"] == "hard-stop", m +assert m["capabilities"] == ["org-secret:CNB_POOL_KEY", "vault:secret/wave/w2c1"], m +assert m["evidence"] == ["gate.merge-verdict"], m +PYEOF + +# ---- 正向:h3 issue 表单形态 + on_exceed 缺省 hard-stop ---- +cat >"$TMP/card-h3.md" <<'EOF' +### 任务 +某卡。 + +### budget(波次预算) +tokens: 1000 + +### capabilities(能力 allowlist) +- vault:secret/x +EOF +OUT=$(python3 "$WS" parse --body-file "$TMP/card-h3.md") +python3 - "$OUT" <<'PYEOF' && pass "parse h3:表单形态+缺省 on_exceed=hard-stop" || fail "parse h3 断言" +import json, sys +m = json.loads(sys.argv[1]) +assert m["budget"] == {"tokens": 1000, "on_exceed": "hard-stop"}, m +assert "evidence" not in m, m +PYEOF + +# ---- 缺块=空对象(缺省语义)---- +cat >"$TMP/card-none.md" <<'EOF' +> 父意图: #402 + +## 任务 +无预算卡。 +EOF +OUT=$(python3 "$WS" parse --body-file "$TMP/card-none.md") +[[ "$OUT" == "{}" ]] && pass "缺块 → 空对象(无预算约束缺省语义)" || fail "缺块应空对象:$OUT" + +# ---- 负向:非法形态逐项 exit 3 ---- +neg() { # neg <名> <内容> + printf '%s\n' "$2" >"$TMP/neg.md" + python3 "$WS" validate --body-file "$TMP/neg.md" >/dev/null 2>&1 + [[ $? -eq 3 ]] && pass "负向:$1 → exit 3" || fail "负向:$1 未拒绝" +} +neg "非法键" '## budget(波次预算) +usd: 1 +euro: 2' +neg "负值" '## budget(波次预算) +usd: -5' +neg "四元组全缺" '## budget(波次预算) +on_exceed: warn' +neg "坏 on_exceed" '## budget(波次预算) +tokens: 1 +on_exceed: explode' +neg "裸 secret 名(非 allowlist 形态)" '## capabilities(能力 allowlist) +- CNB_POOL_KEY' +neg "capabilities 非列表" '## capabilities(能力 allowlist) +foo: bar' +neg "evidence 空列表" '## evidence(证据要求) +[]' +neg "budget 坏 YAML 映射" '## budget(波次预算) +- just +- list' + +# ---- wave-check:统一账本按 subject 聚合 + 超限判定 ---- +cat >"$TMP/cards.json" <<'EOF' +[ + {"number": 500, "body": "## budget(波次预算)\nusd: 10.0\ntokens: 100000\non_exceed: hard-stop"}, + {"number": 501, "body": "## budget(波次预算)\ntokens: 999999\non_exceed: warn"}, + {"number": 502, "body": "## 任务\n无预算卡"}, + {"number": 503, "body": "## budget(波次预算)\neuro: 1"} +] +EOF +mkdir -p "$TMP/ledger" +cat >"$TMP/ledger/shadow-evidence-2026-W35.jsonl" <<'EOF' +{"ts":"2026-08-29T01:00:00Z","kind":"cost","action":"cost.dispatch-burst","verdict":"pass","subject":{"card":"Cloudbird-Software/.github#500","tenant":"cloudbird-internal"},"actor":{"identity":"x","role":"bot","model":null},"cost":{"tokens":40000,"usd":4.0,"wall_sec":600.0},"seq":1,"prev_hash":null,"hash":"aa"} +{"ts":"2026-08-29T02:00:00Z","kind":"cost","action":"cost.dispatch-burst","verdict":"pass","subject":{"card":"Cloudbird-Software/.github#500","tenant":"tenant-b"},"actor":{"identity":"x","role":"bot","model":null},"cost":{"tokens":30000,"usd":7.5,"wall_sec":100.0},"seq":2,"prev_hash":"aa","hash":"bb"} +{"ts":"2026-08-29T03:00:00Z","kind":"cost","action":"cost.dispatch-burst","verdict":"pass","subject":{"card":"Cloudbird-Software/.github#501","tenant":"cloudbird-internal"},"actor":{"identity":"x","role":"bot","model":null},"cost":{"tokens":500,"usd":0.1,"wall_sec":10.0},"seq":3,"prev_hash":"bb","hash":"cc"} +EOF +OUT=$(python3 "$WS" wave-check --cards "$TMP/cards.json" --ledger-dir "$TMP/ledger" 2>"$TMP/wc.err"); RC=$? +python3 - "$OUT" <<'PYEOF' && pass "wave-check 聚合断言(tenant 分离+非法块行级 error+无预算跳过)" || fail "wave-check 聚合断言" +import json, sys +rows = {r["card"]: r for r in json.loads(sys.argv[1])} +c500 = rows["Cloudbird-Software/.github#500"] +assert c500["usage_by_tenant"]["cloudbird-internal"]["usd"] == 4.0, c500 +assert c500["usage_by_tenant"]["tenant-b"]["usd"] == 7.5, c500 +assert c500["usage_total"] == {"usd": 11.5, "tokens": 70000, "wall_sec": 700.0}, c500 +assert c500["exceeded_dims"] == ["usd"], c500 +c501 = rows["Cloudbird-Software/.github#501"] +assert c501["exceeded_dims"] == [] and c501["on_exceed"] == "warn", c501 +assert "error" in rows["Cloudbird-Software/.github#503"], rows +assert "Cloudbird-Software/.github#502" not in rows, rows +PYEOF +[[ $RC -eq 4 ]] && pass "hard-stop 卡超限 → exit 4(BEH-07 熔断触发位)" || fail "超限应 exit 4(rc=$RC)" + +# 无账本目录 → 全零用量不超限 +OUT=$(python3 "$WS" wave-check --cards "$TMP/cards.json" --ledger-dir "$TMP/no-such-dir" 2>/dev/null); RC=$? +[[ $RC -eq 0 ]] && grep -q '"exceeded_dims": \[\]' <<<"$OUT" \ + && pass "账本目录缺失 → 零用量不误熔断" || fail "空账本误判(rc=$RC)" + +echo "----------------------------------------" +if [[ $FAILS -eq 0 ]]; then echo "test-wave-schema: PASS"; exit 0; fi +echo "test-wave-schema: $FAILS 处失败"; exit 1 diff --git a/governance/wave_schema.py b/governance/wave_schema.py new file mode 100644 index 0000000..265c650 --- /dev/null +++ b/governance/wave_schema.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""wave_schema.py —— 卡波次 schema v1 解析/校验(IR-0006 W2-C3 / IFACE-03 / ADR-0103) + +卡 issue body 的可选扩展块(wave-planner 产卡时按需附加;缺省=无预算约束): + + ## budget(波次预算) + usd: 5.0 + tokens: 200000 + wallclock_sec: 7200 + human_minutes: 120 + on_exceed: hard-stop + + ## capabilities(能力 allowlist) + - org-secret:CNB_POOL_KEY + - vault:secret/wave/w2c1 + + ## evidence(证据要求) + - gate.merge-verdict + +约定: +- 块定位:任意标题层级(issue 表单产 h3,手写卡常用 h2)——`^#{2,6}\\s*(budget| + capabilities|evidence)\\b` 起始,至下一 `#` 标题止。 +- budget:四元组 usd/tokens/wallclock_sec/human_minutes(数值 ≥0,至少一项); + on_exceed ∈ {hard-stop, warn}(缺省 hard-stop——声明预算即默认硬停语义, + BEH-07/ADR-0040 复位流程不变)。 +- capabilities:allowlist 式引用,仅两形态——org-secret:<大写名>(org secret 侧) + 或 vault:<路径>(内网域 Vault,值不进 Git)。非 allowlist 引用=非法。 +- evidence:action 词表引用列表(受控词表由发射器侧维护,此处只做非空断言)。 + +子命令: + parse --body-file F [--card owner/repo#n] → stdout wave-meta JSON(缺块=空对象) + validate --body-file F → 只校验(0/3) + wave-check --cards F --ledger-dir D → 逐卡预算对账(cost-check 消费): + 统一账本(shadow-evidence-*.jsonl,schema v1)按 subject.card 聚合 + cost.{usd,tokens,wall_sec},对 budget 四元组比超(human_minutes 无 + 账本源——只报告不判定);输出 JSON 数组(stdout),任一 hard-stop 卡 + 超限 → exit 4。 + +退出码:0=成功 | 2=参数/环境 | 3=块存在但非法(fail-closed)| 4=超限(wave-check) +""" +import argparse +import glob +import json +import os +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None + +BUDGET_KEYS = ("usd", "tokens", "wallclock_sec", "human_minutes") +ON_EXCEED = ("hard-stop", "warn") +SECTION_RE = re.compile(r"^#{2,6}\s*(budget|capabilities|evidence)\b[^\n]*$", + re.MULTILINE) +CAP_RE = re.compile(r"^(org-secret:[A-Z0-9_]+|vault:[A-Za-z0-9/_.-]+)$") + + +def die(code, msg): + print(msg, file=sys.stderr) + sys.exit(code) + + +def parse_blocks(body: str) -> dict: + """提取 budget/capabilities/evidence 三块(yaml 解析;缺块不含键)。""" + out = {} + for m in SECTION_RE.finditer(body or ""): + name = m.group(1).lower() + start = m.end() + nxt = body.find("\n#", start) + chunk = body[start:nxt if nxt != -1 else len(body)] + if yaml is None: + out[name] = {"_raw": chunk.strip()} + continue + try: + val = yaml.safe_load(chunk) if chunk.strip() else None + except yaml.YAMLError: + val = {"_yaml_error": True} + if val is not None: + out[name] = val + return out + + +def validate_blocks(blocks: dict) -> list: + errs = [] + budget = blocks.get("budget") + if budget is not None: + if not isinstance(budget, dict) or "_yaml_error" in budget or "_raw" in budget: + errs.append("budget 块须为 YAML 映射(键: 值)") + else: + extra = set(budget) - set(BUDGET_KEYS) - {"on_exceed"} + if extra: + errs.append(f"budget 含非法键 {sorted(extra)}(合法 {list(BUDGET_KEYS)}+on_exceed)") + nums = [k for k in BUDGET_KEYS if k in budget] + if not nums: + errs.append("budget 四元组至少声明一项(usd/tokens/wallclock_sec/human_minutes)") + for k in nums: + v = budget[k] + if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0: + errs.append(f"budget.{k} 须为非负数值: {v!r}") + oe = budget.get("on_exceed", "hard-stop") + if oe not in ON_EXCEED: + errs.append(f"budget.on_exceed 非法: {oe!r}(合法 {list(ON_EXCEED)};缺省 hard-stop)") + caps = blocks.get("capabilities") + if caps is not None: + if not isinstance(caps, list) or not caps or not all(isinstance(c, str) for c in caps): + errs.append("capabilities 须为非空列表(- org-secret:NAME / - vault:path)") + else: + for c in caps: + if not CAP_RE.match(c.strip()): + errs.append(f"capabilities 条目非法: {c!r}(仅 org-secret:<大写名>/vault:<路径>)") + ev = blocks.get("evidence") + if ev is not None: + if not isinstance(ev, list) or not ev or not all(isinstance(e, str) and e.strip() for e in ev): + errs.append("evidence 须为非空字符串列表(action 词表引用)") + return errs + + +def wave_meta(blocks: dict, card: str = None) -> dict: + meta = {} + if card: + meta["card"] = card + for k in ("budget", "capabilities", "evidence"): + if k in blocks: + v = blocks[k] + if isinstance(v, dict) and "_raw" in v: # 无 yaml:保留原块供人工/下游解析 + meta[k] = {"_raw": v["_raw"]} + elif isinstance(v, dict) and "_yaml_error" in v: + meta[k] = {"_yaml_error": True} + else: + meta[k] = v + if isinstance(meta.get("budget"), dict) and "_raw" not in meta["budget"]: + meta["budget"].setdefault("on_exceed", "hard-stop") # 缺省语义物化(消费方零默认逻辑) + return meta + + +def load_shadow_records(ledger_dir: str) -> list: + """统一账本记录(shadow-evidence-*.jsonl)——聚合源(AC-9b)。""" + recs = [] + for f in sorted(glob.glob(os.path.join(ledger_dir, "shadow-evidence-*.jsonl"))): + with open(f, encoding="utf-8") as fh: + for ln in (l.strip() for l in fh): + if ln: + recs.append(json.loads(ln)) + return recs + + +def wave_check(cards_file: str, ledger_dir: str) -> tuple: + """逐卡对账。返回 (rows, exceeded_any)。 + + 聚合口径:subject.card 精确匹配;tenant 归因分离(AC-9b 多租户计量分离); + cost.{usd,tokens,wall_sec} 求和(无 cost 字段记 0)。human_minutes 无账本源 + ——报告不判定(备注承载)。 + """ + with open(cards_file, encoding="utf-8") as f: + cards = json.load(f) + recs = load_shadow_records(ledger_dir) if os.path.isdir(ledger_dir) else [] + usage = {} # card → tenant → {usd,tokens,wall_sec} + for r in recs: + subj = r.get("subject") or {} + card = subj.get("card") + if not card: + continue + tenant = subj.get("tenant") or "?" + cost = r.get("cost") or {} + u = usage.setdefault(card, {}).setdefault(tenant, {"usd": 0.0, "tokens": 0, "wall_sec": 0.0}) + u["usd"] += float(cost.get("usd") or 0) + u["tokens"] += int(cost.get("tokens") or 0) + u["wall_sec"] += float(cost.get("wall_sec") or 0) + + rows, exceeded = [], False + for c in cards: + number = c.get("number") + body = c.get("body") or "" + blocks = parse_blocks(body) + errs = validate_blocks(blocks) + if errs: + rows.append({"card": f"Cloudbird-Software/.github#{number}", "error": errs[0]}) + continue + budget = blocks.get("budget") + if budget is None: + continue # 无预算块=无约束(缺省语义) + card_ref = f"Cloudbird-Software/.github#{number}" + ten = usage.get(card_ref, {}) + agg = {"usd": sum(t["usd"] for t in ten.values()), + "tokens": sum(t["tokens"] for t in ten.values()), + "wall_sec": sum(t["wall_sec"] for t in ten.values())} + on_exceed = budget.get("on_exceed", "hard-stop") + exceeded_dims = [k for k in BUDGET_KEYS[:3] + if k in budget and agg[k] > float(budget[k])] + if exceeded_dims and on_exceed == "hard-stop": + exceeded = True + rows.append({"card": card_ref, "budget": budget, "usage_by_tenant": ten, + "usage_total": agg, "exceeded_dims": exceeded_dims, + "on_exceed": on_exceed}) + return rows, exceeded + + +def main(): + ap = argparse.ArgumentParser(prog="wave_schema.py", description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("parse") + p.add_argument("--body-file", required=True) + p.add_argument("--card", default=None, help="owner/repo#n(wave-meta 回填 card 字段)") + + p = sub.add_parser("validate") + p.add_argument("--body-file", required=True) + + p = sub.add_parser("wave-check") + p.add_argument("--cards", required=True, help="卡清单 JSON(gh issue list --json number,body 形态)") + p.add_argument("--ledger-dir", required=True, help="统一账本目录(shadow-evidence-*.jsonl)") + + a = ap.parse_args() + if a.cmd in ("parse", "validate"): + with open(a.body_file, encoding="utf-8") as f: + body = f.read() + blocks = parse_blocks(body) + errs = validate_blocks(blocks) + if errs: + for e in errs: + print(f"WAVE-SCHEMA {e}", file=sys.stderr) + die(3, f"波次块非法:{len(errs)} 处(fail-closed——T7 拒绝就绪)") + if a.cmd == "parse": + print(json.dumps(wave_meta(blocks, a.card), ensure_ascii=False, sort_keys=True)) + else: + has = [k for k in ("budget", "capabilities", "evidence") if k in blocks] + print(f"OK {'/'.join(has) if has else '无波次块(缺省语义)'}") + elif a.cmd == "wave-check": + rows, exceeded = wave_check(a.cards, a.ledger_dir) + print(json.dumps(rows, ensure_ascii=False, sort_keys=True)) + if exceeded: + die(4, "存在 hard-stop 卡超限(BEH-07:熔断三件套)") + + +if __name__ == "__main__": + main()