From 3d07b51f44090e1c32e0263256af0ba57a16788c Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:38:13 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(review):=20=E5=A4=84=E7=90=86=20PR#195?= =?UTF-8?q?=20=E5=85=A8=E9=83=A8=20AI=20review=20=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0=E9=97=AE=E9=A2=98=EF=BC=88W1-C3=20#166=EF=BC=8CADR-00?= =?UTF-8?q?55=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐条复现后修复(不可复现的未动,PR 回复里说明): - ghcb status:租约查询 ref 去 refs/ 前缀(git/ref API 带前缀恒 404 实测,误报"无活跃租约");claim/release/next 补 norm_repo(短仓名 gh 拒收实测) - drift §17:PROTO_REPOS 复用 §7 REPO_MAP(jq 直读 YAML 必失败被吞=假绿;解析缺席/清单空均 fail-closed);proto_block 缺末标记不再输出半块;diff 双 here-string 改进程替换(SC2261) - dashboard-update:sli_stuck 分页;_req 去无效 ok_codes+label 创建显式查状态码;标题清洗+8 反引号 fence;ensure_issue state=all+排除 PR;_stable 比对剥离 generated_at - board-sync:多 state:* 标签排序取首+WARN;projectsV2 游标翻页;unknown-state 告警文案对齐实际行为 - board-sync.yml:source butler-audit.sh 失败 fail-closed - conductor.yml:issue 输出前置;swap_state/assignee 写结果检查,/claim 写失败先 adjudicate /release 补偿再 fail-closed - Makefile:REPO 行尾注释移独立行(尾随空格混入 gh -R) - AGENTS.md:协议块 curl 补 -f;AG-1 增补 GOVERNANCE_TOKEN 例外(App 实测无 organization_projects/members 权限;ADR-0055 决策 7 同步记录) 本地验证:修复前红/修复后绿复现脚本;§17 五场景;conductor 编译+补偿模拟;ghcb 桩测;bash -n+yaml+py_compile 全过 --- .github/workflows/board-sync.yml | 6 ++- .github/workflows/conductor.yml | 82 +++++++++++++++++++++--------- AGENTS.md | 4 +- Makefile | 4 +- governance/board-sync.py | 28 +++++++--- governance/dashboard-update.py | 87 ++++++++++++++++++++++++-------- governance/drift-check.sh | 29 ++++++++--- scripts/ghcb | 17 ++++--- 8 files changed, 185 insertions(+), 72 deletions(-) mode change 100644 => 100755 scripts/ghcb diff --git a/.github/workflows/board-sync.yml b/.github/workflows/board-sync.yml index 862ddec..ad46315 100644 --- a/.github/workflows/board-sync.yml +++ b/.github/workflows/board-sync.yml @@ -33,7 +33,11 @@ jobs: BUTLER_TRIGGER: ${{ github.event_name }} run: | set -uo pipefail - source governance/butler-audit.sh + # 审计发射器加载失败=检测器失明——fail-closed,不得让投影在无审计下静默运行 + if ! source governance/butler-audit.sh || ! command -v audit_emit >/dev/null; then + echo "::error::governance/butler-audit.sh 加载失败或未定义 audit_emit(审计失明——fail-closed)" >&2 + exit 2 + fi if ! python3 governance/board-sync.py; then audit_emit board-sync manual infra-fail '{"rc":"nonzero"}' || true echo "::error::board-sync.py 失败(fail-closed——投影失败不得静默,ADR-0055)" >&2 diff --git a/.github/workflows/conductor.yml b/.github/workflows/conductor.yml index 0fedd29..3125274 100644 --- a/.github/workflows/conductor.yml +++ b/.github/workflows/conductor.yml @@ -86,6 +86,11 @@ jobs: E = os.environ ORG, REPO = "Cloudbird-Software", E["REPO"] ISSUE = E["ISSUE_NUMBER"] + # issue 输出最先落盘:后续任何失败路径(读 issue 失败/仲裁 infra/状态写失败) + # 都会触发 on-failure,届时 needs.route.outputs.issue 必须非空(BEH-01 通知要求) + if E.get("GITHUB_OUTPUT"): + with open(E["GITHUB_OUTPUT"], "a", encoding="utf-8") as _o: + _o.write(f"issue={ISSUE}\n") def api(token, path, method="GET", body=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(f"https://api.github.com{path}", data=data, method=method, @@ -184,10 +189,15 @@ jobs: audit(f"verdict=abort guard 求值失败 {t['id']}: {e}"); raise SystemExit(1) def swap_state(frm, to): - enc_from = urllib.parse.quote(f"state:{frm}", safe="") - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3A{frm}", "DELETE") - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels", - "POST", {"labels": [f"state:{to}"]}) + # 写结果逐项检查(ADR-0055):标签写失败=状态面失真——不许“路由记 allow + # 但卡未变更”静默成功,抛 WriteFail 交上层 fail-closed/租约补偿 + st_del, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3A{frm}", "DELETE") + if st_del not in (200, 204): + raise WriteFail(f"删标签 state:{frm} HTTP {st_del}") + st_add, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels", + "POST", {"labels": [f"state:{to}"]}) + if st_add not in (200, 201): + raise WriteFail(f"加标签 state:{to} HTTP {st_add}") if not ok: # 静默丢弃(AC-11):回退标签、不评论、不启动 @@ -212,28 +222,50 @@ jobs: audit(f"event={ev} transition={t['id']} sender_role={role} arbiter=allow " f"(租约已建——T3 落地;TTL 到期由下一 /claim 原子接管,ADR-0054)") - # ---- 执行转移(状态标签写=App 身份,INV-02)---- + # ---- 执行转移(状态标签写=App 身份,INV-02;写失败=fail-closed, + # /claim 已建租约时先补偿回滚——杜绝“租约在、卡未变”的不一致面,ADR-0055)---- + class WriteFail(Exception): + pass + out = open(E["GITHUB_OUTPUT"], "a", encoding="utf-8") - action = t["action"] - if action == "invoke:spec-author": - m = re.match(r"(IR-\d+)", iss.get("title") or "") - task_id = m.group(1) if m else f"ISSUE-{ISSUE}" - swap_state(t["from_state"], t["to_state"]) - out.write(f"invoke=spec-author\nissue={ISSUE}\nir_ref={task_id} {REPO}#{ISSUE}\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"{t['from_state']}->{t['to_state']} action=invoke:spec-author") - elif action == "claim": - swap_state(t["from_state"], t["to_state"]) - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/assignees", "POST", {"assignees": [actor]}) - out.write("invoke=none\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"claim->in-progress assignee={actor}") - else: - swap_state(t["from_state"], t["to_state"]) - out.write("invoke=none\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"{t['from_state']}->{t['to_state']} action=noop") - out.close() + try: + action = t["action"] + if action == "invoke:spec-author": + m = re.match(r"(IR-\d+)", iss.get("title") or "") + task_id = m.group(1) if m else f"ISSUE-{ISSUE}" + swap_state(t["from_state"], t["to_state"]) + out.write(f"invoke=spec-author\nir_ref={task_id} {REPO}#{ISSUE}\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"{t['from_state']}->{t['to_state']} action=invoke:spec-author") + elif action == "claim": + lease_created = ev == "comment:/claim" # 裁决 allow 时租约已建 + try: + swap_state(t["from_state"], t["to_state"]) + st_as, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/assignees", + "POST", {"assignees": [actor]}) + if st_as not in (200, 201): + raise WriteFail(f"置 assignee HTTP {st_as}") + except WriteFail as e: + if lease_created: + # 补偿:同 sender 裁决 /release(holder 本人可成)→ 租约回滚, + # 卡回到可重试态;随后 fail-closed 退出,delivery 幂等可安全重投 + audit(f"event={ev} transition={t['id']} write-fail {e}" + f"——补偿回滚刚建的租约(adjudicate /release)") + adjudicate("/release") + raise + out.write("invoke=none\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"claim->in-progress assignee={actor}") + else: + swap_state(t["from_state"], t["to_state"]) + out.write("invoke=none\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"{t['from_state']}->{t['to_state']} action=noop") + except WriteFail as e: + audit(f"event={ev} verdict=ABORT 状态写失败 {e}(fail-closed;delivery 幂等可安全重投)") + raise SystemExit(1) + finally: + out.close() PYEOF - name: 幂等键落盘({issue, from, to}——重复投递复核凭据) if: steps.route.outputs.invoke == 'spec-author' diff --git a/AGENTS.md b/AGENTS.md index 07cdc5e..fef2886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ### 入口协议(陌生 agent 从这里开始——宪法 §11 / ADR-0055) -1. 取 ghcb(钉 SHA,禁浮动 main):`curl -sS -o ghcb https://raw.githubusercontent.com/Cloudbird-Software/.github/f72d9520706c8fca974d92456f65cae5c1412bb7/scripts/ghcb && chmod +x ghcb`(凭据用你自己的:`gh auth login` 或 `export GH_TOKEN=`) +1. 取 ghcb(钉 SHA,禁浮动 main):`curl -fsS -o ghcb https://raw.githubusercontent.com/Cloudbird-Software/.github/f72d9520706c8fca974d92456f65cae5c1412bb7/scripts/ghcb && chmod +x ghcb`(凭据用你自己的:`gh auth login` 或 `export GH_TOKEN=`;`-f` 必带——404 时 curl 无 -f 仍退出 0,会把错误页当脚本落盘) 2. 找活:`bash ghcb next [owner/repo]` → 列 state:ready 卡(卡 issue 是唯一工作凭证,无卡不开工) 3. 认领:`bash ghcb claim [owner/repo]` → 评论 /claim——conductor 转介 arbiter 原子 CAS 租约,先到先得;败者换下一张(`bash ghcb status ` 看持有者) 4. 开工:`make card-test CARD=`(读卡 AC、测试先行)→ `make gates-pr`(本地复现 CI 关卡) @@ -18,7 +18,7 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ## 硬规则 - 治理文件(governance/ standards/ scripts/ .github/ CODEOWNERS profile/ Makefile docs/)= C1 路径:PR 必须引用 ADR-NNNN,owner-only review(GOVERNANCE flows.governance_change;与 gate adr-required 机器检查同路径集) -- agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期(本仓驻留 agent 直接用 `scripts/ghcb`,等价协议块下载版) +- agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期(本仓驻留 agent 直接用 `scripts/ghcb`,等价协议块下载版)。例外:org 级 Project(v2) 写与组织成员判定(App 无 organization_projects/members 权限,ADR-0055 决策 8)用 GOVERNANCE_TOKEN(org admin PAT,仅 workflow secrets 面,不落 agent 手) - 本仓只读治理声明;ADR 与注册条目落盘 agent-registry(REPOS.yaml L1) - 不引入新第三方 Action:白名单见 expected-state.json#actions_policy(CI-2) - 无人值守护栏(ADR-0040,跨仓生效):(a) 每次任务派发与 `gh pr merge --auto` 前,必须检查 org 变量 `AUTO_MERGE_DISABLED`(`gh api /orgs/Cloudbird-Software/actions/variables/AUTO_MERGE_DISABLED --jq .value`,404=未置位)——置位即停一切派发与 automerge,禁止任何绕过尝试;(b) 同一 PR 的修红重试 ≤ policy/automation-limits.yaml `auto_fix.max_attempts`(默认 3),达上限即停手(auto-fix-limit workflow 会关 PR + 开 issue);(c) 不得 reopen 带 `auto-fix-limit-exhausted` 标签的 PR;计数真源 = Checks API(commit 元数据),删标签/重开不重置计数;(d) 派发前确认 .github 仓无未决 `cost-infra`/`cost-circuit-breaker` issue(用量不可知时同样停) diff --git a/Makefile b/Makefile index 0543dd1..48ca757 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,9 @@ # gates-pr 真实执行 gate.yml 的本地可等价部分(bash -n / yaml 解析),CI 关卡语义 # 仍以 .github/workflows/gate.yml 为准,不伪装已运行 CI。 CARD ?= -REPO ?= Cloudbird-Software/.github # 卡所在仓(W1 波次卡都在治理仓;产品仓自有卡时 REPO=... 覆盖) +# 卡所在仓(W1 波次卡都在治理仓;产品仓自有卡时 REPO=... 覆盖)。 +# 注释须独立成行:行尾注释会把 # 前的尾随空格并入 REPO 值,gh -R 解析失败且被吞。 +REPO ?= Cloudbird-Software/.github .PHONY: card-test gates-pr card-test: ## 读卡 AC 列表并提示测试先行:make card-test CARD= diff --git a/governance/board-sync.py b/governance/board-sync.py index 3261e1d..fd93d32 100644 --- a/governance/board-sync.py +++ b/governance/board-sync.py @@ -170,9 +170,13 @@ def scan_cards(repos): for it in batch: if "pull_request" in it: # issues 端点混入 PR——不是卡 continue - sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + sl = sorted(l["name"] for l in it.get("labels", []) + if str(l.get("name", "")).startswith("state:")) if not sl: continue + if len(sl) > 1: # 真相源唯一性被破坏(宪法 §12)——排序取首保证两投影一致 + print(f"WARN multi-state {repo}#{it['number']}: {sl}" + f"——多 state 标签并存,本轮取 {sl[0]},请修标签") body = it.get("body") or "" boxes = re.findall(r"^\s*[-*]\s+\[( |x|X)\]", body, re.M) cards.append({ @@ -192,8 +196,9 @@ def scan_cards(repos): # ---------- Project(v2) 幂等准备 ---------- -Q_ORG = """query($org:String!){ organization(login:$org){ - id projectsV2(first:100){ nodes{ id title url } } } }""" +Q_ORG = """query($org:String!,$cur:String){ organization(login:$org){ + id projectsV2(first:100, after:$cur){ nodes{ id title url } + pageInfo{ hasNextPage endCursor } } } }""" Q_FIELDS = """query($pid:ID!){ node(id:$pid){ ... on ProjectV2 { fields(first:50){ nodes{ __typename ... on ProjectV2Field{ id name dataType } @@ -228,12 +233,19 @@ def scan_cards(repos): def ensure_project(): - org = gql(Q_ORG, {"org": ORG})["organization"] + org = gql(Q_ORG, {"org": ORG, "cur": None})["organization"] if org is None: raise Infra(f"organization {ORG} 不可见(GOVERNANCE_TOKEN 权限?)") - for p in (org.get("projectsV2") or {}).get("nodes") or []: - if p.get("title") == PROJECT_TITLE: - return p["id"], p.get("url") or "" + # 游标翻页遍历全量后再判“不存在”(org 项目 >100 时不翻页会重复建同名板) + while True: + conn = (org.get("projectsV2") or {}) + for p in conn.get("nodes") or []: + if p.get("title") == PROJECT_TITLE: + return p["id"], p.get("url") or "" + if not conn.get("pageInfo", {}).get("hasNextPage"): + break + cur = conn["pageInfo"]["endCursor"] + org = gql(Q_ORG, {"org": ORG, "cur": cur})["organization"] if DRY_RUN: print(f"[dry-run] 将创建 org Project(v2)「{PROJECT_TITLE}」") return None, "" @@ -351,7 +363,7 @@ def main(): key = (c["repo"], c["number"]) if c["state"] not in state_names: print(f"WARN unknown-state {c['repo']}#{c['number']}: label 态 {c['state']} " - f"不在 expected-state 全集——字段照设为文本态名,请修标签") + f"不在 expected-state 全集——State 无对应单选选项,将跳过 State 写入(报警留观),请修标签") entry = board.get(key) preexisting = entry is not None # 报警面只认"板上有旧值"的漂移(新增不算) if entry is None: diff --git a/governance/dashboard-update.py b/governance/dashboard-update.py index 12ad88f..3321912 100644 --- a/governance/dashboard-update.py +++ b/governance/dashboard-update.py @@ -45,13 +45,20 @@ TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" APP_BOT = "cloudbrid-agent[bot]" JSON_MARK = "" +FENCE = "`" * 8 # 长于任何合理用户输入的 fence(标题可含 ```——防截断机器可读区) + + +def _safe_text(s): + """剥离用户可控文本里可破坏 fence / 伪造区标记的字面量(标题进 body 的必经清洗)。""" + return (str(s or "").replace("`", "'") + .replace("", "-- >")) class Infra(Exception): pass -def _req(url, body=None, method=None, ok_codes=(200, 201)): +def _req(url, body=None, method=None): # 状态码判定归调用方(send/显式检查)——不设 ok_codes 形参以免“声明了却不用” headers = {"Authorization": f"Bearer {TOKEN}", "User-Agent": "dashboard-update", "Accept": "application/vnd.github+json"} data = json.dumps(body).encode() if body is not None else None @@ -80,7 +87,7 @@ def get(path): def send(method, path, body, ok_codes=(200, 201)): - st, payload = _req(f"{GH_API}{path}", body, method, ok_codes) + st, payload = _req(f"{GH_API}{path}", body, method) if st not in ok_codes: raise Infra(f"{method} {path} HTTP {st}: {str(payload.get('message'))[:160]}") return payload @@ -112,10 +119,14 @@ def scan_cards(repos): for it in batch: if "pull_request" in it: continue - sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + sl = sorted(l["name"] for l in it.get("labels", []) + if str(l.get("name", "")).startswith("state:")) if not sl: continue - cards.append({"repo": repo, "number": it["number"], "title": it["title"], + if len(sl) > 1: # 真相源唯一性被破坏(宪法 §12)——排序取首保证两投影一致 + print(f"WARN multi-state {repo}#{it['number']}: {sl}" + f"——多 state 标签并存,本轮取 {sl[0]},请修标签") + cards.append({"repo": repo, "number": it["number"], "title": _safe_text(it["title"]), "state": sl[0][len("state:"):], "assignee": (it.get("assignees") or [{}])[0].get("login", ""), "url": it["html_url"], "updated_at": it.get("updated_at") or "", @@ -175,8 +186,13 @@ def sli_stuck(repos): cutoff = NOW - _dt.timedelta(hours=24) stuck = 0 for repo in repos: - prs = get(f"/repos/{ORG}/{repo}/pulls?state=open&per_page=100") - stuck += sum(1 for pr in prs if _iso(pr.get("created_at")) < cutoff) + page = 1 # 分页拉全量(>100 open PR 单页漏计——与 scan_cards 同教训) + while True: + prs = get(f"/repos/{ORG}/{repo}/pulls?state=open&per_page=100&page={page}") + stuck += sum(1 for pr in prs if _iso(pr.get("created_at")) < cutoff) + if len(prs) < 100: + break + page += 1 return stuck @@ -219,9 +235,9 @@ def render_body(payload): ## 机器可读区(agent 一次读取全局;历史留痕=本 issue 编辑历史) {JSON_MARK} -```json +{FENCE}json {json.dumps(payload, ensure_ascii=False, indent=2)} -``` +{FENCE} ## 人类一屏摘要 @@ -236,21 +252,29 @@ def render_body(payload): def ensure_issue(body): - """幂等找到/创建账本 issue;返回 (number, created)。""" + """幂等找到/创建账本 issue;返回 (number, created)。 + + 查找范围 state=all(含已关闭:账本被人工关闭后复用之,不得重复创建—— + 否则账本分裂、编辑历史散落);/issues 端点混入 PR,须按 "pull_request" + 键排除后再匹配标题。 + """ found = None page = 1 while True: - batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=open&per_page=100&page={page}") - found = next((i for i in batch if i["title"] == ISSUE_TITLE), None) + batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=all&per_page=100&page={page}") + found = next((i for i in batch + if "pull_request" not in i and i["title"] == ISSUE_TITLE), None) if found or len(batch) < 100: break page += 1 if found: return found["number"], False - # 幂等建 label(422=已存在,容忍) - _req(f"{GH_API}/repos/{ORG}/{HOME_REPO}/labels", - {"name": LABEL["name"], "color": LABEL["color"], "description": LABEL["description"]}, - "POST", ok_codes=(201, 422)) + # 幂等建 label(201=新建,422=已存在;其余=真故障——fail-closed 不静默) + st, payload = _req(f"{GH_API}/repos/{ORG}/{HOME_REPO}/labels", + {"name": LABEL["name"], "color": LABEL["color"], + "description": LABEL["description"]}, "POST") + if st not in (201, 422): + raise Infra(f"POST labels HTTP {st}: {str(payload.get('message'))[:160]}") if DRY_RUN: print(f"[dry-run] 将创建 dashboard 账本 issue「{ISSUE_TITLE}」") return None, True @@ -260,20 +284,39 @@ def ensure_issue(body): def project_url(): - """只读取 factory-floor 项目链接(board-sync 已建;失败不阻塞账本——置空)。""" + """只读取 factory-floor 项目链接(board-sync 已建;失败不阻塞账本——置空)。 + + projectsV2 游标翻页(与 board-sync.ensure_project 同判据):org 项目 >100 时 + 目标不在首页,不翻页会把“存在”误判为“不存在”。 + """ try: - st, payload = _req(f"{GH_API}/graphql", { - "query": "query($o:String!){ organization(login:$o){ projectsV2(first:100){ nodes{ title url } } } }", - "variables": {"o": ORG}}, "POST") - if st == 200 and not payload.get("errors"): - for p in payload["data"]["organization"]["projectsV2"]["nodes"]: + cur = None + while True: + st, payload = _req(f"{GH_API}/graphql", { + "query": "query($o:String!,$cur:String){ organization(login:$o){" + " projectsV2(first:100, after:$cur){ nodes{ title url }" + " pageInfo{ hasNextPage endCursor } } } }", + "variables": {"o": ORG, "cur": cur}}, "POST") + if st != 200 or payload.get("errors"): + break + conn = payload["data"]["organization"]["projectsV2"] + for p in conn["nodes"]: if p["title"] == "factory-floor": return p["url"] + if not conn["pageInfo"]["hasNextPage"]: + break + cur = conn["pageInfo"]["endCursor"] except Exception: pass return "" +def _stable(body): + """剥离每轮必变的时间戳再比对(generated_at 精度到秒——不剥离则“内容相同 + 跳过写”永不生效,每 15min 一条无意义编辑淹没 issue 历史)。""" + return re.sub(r'"generated_at":\s*"[^"]*"', '"generated_at":"-"', body).strip() + + def main(): if not TOKEN: print("FATAL 需要环境变量 GH_TOKEN=GOVERNANCE_TOKEN", file=sys.stderr) @@ -294,7 +337,7 @@ def main(): return 0 if not created: cur = get(f"/repos/{ORG}/{HOME_REPO}/issues/{num}") - if (cur.get("body") or "").strip() == body.strip(): + if _stable(cur.get("body") or "") == _stable(body): stats["unchanged"] = 1 elif DRY_RUN: print(f"[dry-run] 将编辑 issue #{num} body({len(body)} 字节)") diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 7baf9f0..da6e41b 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -733,10 +733,26 @@ fi # 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 # entry_protocol: true 即纳入本节管辖。 PROTO_OK=0 -PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' \ - "$DIR/REPOS.yaml" 2>/dev/null || echo "") -proto_block() { # 提取协议块(标记间内容,含首尾标记行)——无标记则输出空 - awk '//{f=1} f{print} //{f=0}' +PROTO_REPOS="" +# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。复用 §7 已生成的 +# REPO_MAP(python3+pyyaml→JSON);解析不可用或 entry_protocol 清单为空均 fail-closed。 +if [[ -n "${REPO_MAP:-}" ]]; then + PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$REPO_MAP") + if [[ -z "$PROTO_REPOS" ]]; then + drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" + PROTO_OK=1 + fi +else + drift "REPOS.yaml 未解析(缺 python3+pyyaml,REPO_MAP 缺席)——协议块对账不可执行(fail-closed,ADR-0055 §17)" + PROTO_OK=1 +fi +proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空 + # (缺末标记时不得输出“半块”:半块≠空,会让比对产生误判——fail-closed) + local t + t=$(cat) + if [[ "$t" == *""* ]]; then + awk '//{f=1} f{print} //{exit}' <<<"$t" + fi } CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) CANON_BLOCK="" @@ -762,12 +778,13 @@ for r in $PROTO_REPOS; do fi BLOCK=$(proto_block <<<"$RAW") if [[ -z "$BLOCK" ]]; then - drift "repo '$r' AGENTS.md 缺协议块标记(entry_protocol 已申报——宪法 §11 统一入口)" + drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" PROTO_OK=1 continue fi if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then - drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <<<"$CANON_BLOCK" <<<"$BLOCK" | head -3 | tr '\n' ' ' | cut -c1-160))" + # 双 here-string 会互相覆盖 stdin(SC2261,diff 拿不到操作数)——用进程替换 + drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <(printf '%s\n' "$CANON_BLOCK") <(printf '%s\n' "$BLOCK") 2>&1 | head -3 | tr '\n' ' ' | cut -c1-160))" PROTO_OK=1 fi done diff --git a/scripts/ghcb b/scripts/ghcb old mode 100644 new mode 100755 index 40a92cb..f4addea --- a/scripts/ghcb +++ b/scripts/ghcb @@ -33,7 +33,7 @@ case "$CMD" in next) REPO_ARG="${2:-$(origin_repo)}" [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓(用法: ghcb next [repo])" >&2; exit 2; } - gh issue list --repo "$REPO_ARG" --label state:ready --state open --json number,title --jq '.[] | "#\(.number) \(.title)"' || { echo "查询失败:检查 gh 凭据(gh auth login)与仓权限" >&2; exit 2; } + gh issue list --repo "$(norm_repo "$REPO_ARG")" --label state:ready --state open --json number,title --jq '.[] | "#\(.number) \(.title)"' || { echo "查询失败:检查 gh 凭据(gh auth login)与仓权限" >&2; exit 2; } ;; claim) # 参数序 [repo](首版误把 $1 当 repo,PR#157 修正) @@ -43,7 +43,7 @@ case "$CMD" in # MSYS 路径转换豁免:Git Bash 会把以 / 开头的参数改写成 Windows 路径 # (实测 "/claim"→"D:/development/Git/claim",conductor 白名单不认); # 非 MSYS 环境该 env 为无害空设 - MSYS2_ARG_CONV_EXCL='/claim' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/claim" >/dev/null + MSYS2_ARG_CONV_EXCL='/claim' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$(norm_repo "$REPO_ARG")" --body "/claim" >/dev/null echo "已评论 /claim(#$N @$REPO_ARG)——conductor 转介 arbiter CAS 裁决并置 state:in-progress;确认:ghcb status $N" ;; release) @@ -51,7 +51,7 @@ case "$CMD" in N="${2:?用法: ghcb release [repo]}" REPO_ARG="${3:-$(origin_repo)}" [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓" >&2; exit 2; } - MSYS2_ARG_CONV_EXCL='/release' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/release" >/dev/null + MSYS2_ARG_CONV_EXCL='/release' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$(norm_repo "$REPO_ARG")" --body "/release" >/dev/null echo "已评论 /release(#$N @$REPO_ARG)——conductor 转介 arbiter 释放租约(仅 holder/owner);确认:ghcb status $N" ;; status) @@ -63,14 +63,17 @@ case "$CMD" in gh issue view "$N" --repo "$FULL" --json number,title,state,labels,assignees \ --jq '"#\(.number) \(.title)\n 状态: \(.state) | 标签: \([.labels[].name] | join(", ")) | assignee: \([.assignees[].login] | join(", "))"' \ || { echo "查询失败:检查 gh 凭据与仓权限" >&2; exit 2; } - REF="refs/leases/${ORG}__${NAME}__${N}" - if SHA=$(gh api "repos/$LEAS_REPO/git/ref/$REF" --jq '.object.sha' 2>/dev/null); then + # GET /git/ref/{ref} 的 ref 参数不带 refs/ 前缀(heads/main 而非 refs/heads/main; + # 带 refs/ 前缀恒 404——实测),展示仍用完整 ref 名符合 git 语义 + REF_FULL="refs/leases/${ORG}__${NAME}__${N}" + REF_API="leases/${ORG}__${NAME}__${N}" + if SHA=$(gh api "repos/$LEAS_REPO/git/ref/$REF_API" --jq '.object.sha' 2>/dev/null); then MSG=$(gh api "repos/$LEAS_REPO/git/commits/$SHA" --jq '.message' 2>/dev/null || true) HOLDER=$(grep -o '"holder": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"holder": *"//; s/"$//' || true) EXP=$(grep -o '"expires_at": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"expires_at": *"//; s/"$//' || true) - echo " 租约: holder=${HOLDER:-?} expires_at=${EXP:-?}(ref $REF)" + echo " 租约: holder=${HOLDER:-?} expires_at=${EXP:-?}(ref $REF_FULL)" else - echo " 租约: 无活跃租约(ref $REF 不存在)" + echo " 租约: 无活跃租约(ref $REF_FULL 不存在)" fi ;; card-meta) From fb4f5e5562020bf51d5b14d21cfcbd63a3f0e20e Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:45:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(gate):=20adr-required=20INDEX=20?= =?UTF-8?q?=E4=B8=96=E7=95=8C=20python=20-c=20=E7=BC=A9=E8=BF=9B=E5=8B=98?= =?UTF-8?q?=E8=AF=AF=EF=BC=88#174=20=E5=BC=95=E5=85=A5=E2=80=94=E2=80=94YA?= =?UTF-8?q?ML=20=E5=89=A5=E5=9F=BA=E5=87=86=E7=BC=A9=E8=BF=9B=E5=90=8E=20i?= =?UTF-8?q?mport=20=E9=A1=B6=E6=A0=BC=EF=BC=9BC1=20PR=20gate=20=E5=85=A8?= =?UTF-8?q?=E7=BA=A2=E6=A0=B9=E5=9B=A0=EF=BC=9Bzfill=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=20f-string=20=E5=8F=8D=E6=96=9C=E6=9D=A0=E8=BD=AC=E4=B9=89?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=203.11=EF=BC=89=E2=80=94=E2=80=94ADR-0053?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gate.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 777f61f..581cd9f 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -170,13 +170,15 @@ jobs: # entry → "NNNN archive_path" 行(pyyaml 已随本 job 前置步骤安装); # archive_path 为空的 entry 不入 map(#96 扩展点:后续 substantive 字段 # 不影响本映射——number/archive_path 是本关卡唯一消费的键) + # python -c 代码体须与本 run 块基准缩进对齐(10 空格,YAML 剥离后 + # 才是第 0 列)——#174 引入时多缩 2 格,INDEX 世界首跑即 IndentationError python3 -c ' - import yaml - d = yaml.safe_load(open("/tmp/adr_index.yaml", encoding="utf-8")) - for e in d.get("entries") or []: - p = e.get("archive_path") - if p: - print(f"{int(e[\"number\"]):04d} {p}")' >/tmp/adr_map.txt + import yaml + d = yaml.safe_load(open("/tmp/adr_index.yaml", encoding="utf-8")) + for e in d.get("entries") or []: + p = e.get("archive_path") + if p: + print(str(int(e["number"])).zfill(4), p)' >/tmp/adr_map.txt if [[ ! -s /tmp/adr_map.txt ]]; then echo "::error::INDEX.yaml 存在但 entries 为空/解析失败——索引世界不可判定,fail-closed" exit 1 From 313dc77aa01ee3f2e7392bdfeab1c2c6ee737268 Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:59:24 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(review-2):=20=E5=A4=84=E7=90=86=20#208?= =?UTF-8?q?=20=E7=AC=AC=E4=BA=8C=E8=BD=AE=20AI=20review=20=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=E9=97=AE=E9=A2=98=EF=BC=88W1-C3=20#166?= =?UTF-8?q?=EF=BC=8CADR-0055/0054=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conductor api():容 204 空响应体(DELETE label 返回 204 无 body,json.load 空体必抛 JSONDecodeError——原版 swap_state 首个 DELETE 即崩,已桩测复现) - conductor /claim 补偿三勘误:(1) 补偿 /release 复用原 delivery-id 被 arbiter seen_ref 判 replay no-op(kernel 实读:幂等键=sha1(delivery-id) 与命令无关)→ 独立 compensate id;(2) 只放租约不恢复状态标签,卡停 in-progress 不可重试 → 先 swap 回 from_state(DELETE 容 404 幂等);(3) 补偿 rc 入审计,任一失败仍 fail-closed exit 1 - butler-ledger.yml:source butler-audit.sh 补同款 fail-closed 守卫(生产 15min 驱动面) - AGENTS.md:GH_TOKEN= 命令标注 owner/CI 专属(与 AG-1 例外措辞一致) - board-sync:closed 条目 final 态补排序(与 scan_cards 同判据) - dashboard:state 入 fence 前过 _safe_text(标签可含 8 反引号);ensure_issue 复用须带 dashboard label(标题不唯一) - drift §17:REPOS.yaml 自解析(不信任继承 REPO_MAP——env 注入可重定向对账) 本地验证:api() 204 单测;补偿模拟(状态恢复+独立 delivery-id+rc 审计);§17 六场景;state 清洗;gates 全过 --- .github/workflows/butler-ledger.yml | 6 ++++- .github/workflows/conductor.yml | 35 ++++++++++++++++++++--------- AGENTS.md | 7 +++--- governance/board-sync.py | 3 ++- governance/dashboard-update.py | 8 ++++--- governance/drift-check.sh | 16 ++++++++----- 6 files changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/butler-ledger.yml b/.github/workflows/butler-ledger.yml index a791d5f..0bb45e2 100644 --- a/.github/workflows/butler-ledger.yml +++ b/.github/workflows/butler-ledger.yml @@ -34,7 +34,11 @@ jobs: BUTLER_TRIGGER: ${{ github.event_name }} run: | set -uo pipefail - source governance/butler-audit.sh + # 审计发射器加载失败=检测器失明——fail-closed(与 board-sync.yml 同款守卫) + if ! source governance/butler-audit.sh || ! command -v audit_emit >/dev/null; then + echo "::error::governance/butler-audit.sh 加载失败或未定义 audit_emit(审计失明——fail-closed)" >&2 + exit 2 + fi TRIGGER="${BUTLER_TRIGGER:-manual}" # --- W1-C3 投影脚本一:board-sync.py(守卫:未落地=skipped 保持绿) --- if [[ -f governance/board-sync.py ]]; then diff --git a/.github/workflows/conductor.yml b/.github/workflows/conductor.yml index 3125274..90aefc3 100644 --- a/.github/workflows/conductor.yml +++ b/.github/workflows/conductor.yml @@ -98,7 +98,10 @@ jobs: "User-Agent": "conductor"}) try: with urllib.request.urlopen(req) as r: - return r.status, json.load(r) + raw = r.read() + # 204(DELETE label 等)响应体为空——json.load 空体必抛 + # JSONDecodeError,状态码拿不到就 fail-closed 不成立 + return r.status, (json.loads(raw) if raw.strip() else {}) except urllib.error.HTTPError as e: return e.code, {} @@ -158,11 +161,14 @@ jobs: # ---- arbiter 前置裁决(ADR-0055:/claim /release 转介;退出码三态)---- # cwd 必须是 arbiter checkout 根(python -m arbiter.cli 的包根在那里) ARBITER_DIR = os.path.join(os.getcwd(), "arbiter") - def adjudicate(command): + def adjudicate(command, delivery_id=None): + # delivery_id 缺省=评论 node_id(重投幂等键);补偿调用必须传独立 id—— + # arbiter seen_ref 只按 sha1(delivery_id) 判重放(kernel §2,与命令无关), + # 复用原 id 的补偿 /release 会被判 replay no-op,租约释放不掉 argv = ["bash", os.path.join(ARBITER_DIR, "scripts", "adjudicate.sh"), command, "--card", f"{REPO}#{ISSUE}", "--sender", actor, "--sender-role", role, - "--delivery-id", E.get("COMMENT_NODE_ID") or f"run-{E.get('RUN_ID', 'unknown')}", + "--delivery-id", delivery_id or (E.get("COMMENT_NODE_ID") or f"run-{E.get('RUN_ID', 'unknown')}"), "--event", "created", "--current-state", current, "--backend", "github"] return subprocess.call(argv, cwd=ARBITER_DIR) @@ -190,9 +196,10 @@ jobs: def swap_state(frm, to): # 写结果逐项检查(ADR-0055):标签写失败=状态面失真——不许“路由记 allow - # 但卡未变更”静默成功,抛 WriteFail 交上层 fail-closed/租约补偿 + # 但卡未变更”静默成功,抛 WriteFail 交上层 fail-closed/租约补偿。 + # DELETE 容 404:标签已不在=目标态已达成(幂等删/补偿恢复场景) st_del, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3A{frm}", "DELETE") - if st_del not in (200, 204): + if st_del not in (200, 204, 404): raise WriteFail(f"删标签 state:{frm} HTTP {st_del}") st_add, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels", "POST", {"labels": [f"state:{to}"]}) @@ -247,11 +254,19 @@ jobs: raise WriteFail(f"置 assignee HTTP {st_as}") except WriteFail as e: if lease_created: - # 补偿:同 sender 裁决 /release(holder 本人可成)→ 租约回滚, - # 卡回到可重试态;随后 fail-closed 退出,delivery 幂等可安全重投 - audit(f"event={ev} transition={t['id']} write-fail {e}" - f"——补偿回滚刚建的租约(adjudicate /release)") - adjudicate("/release") + # 补偿双面:状态标签(宪法 §12 真相源——先恢复,卡回到 + # 可重试态)+ 租约(独立 delivery id:复用原 id 会被 + # arbiter seen_ref 判 replay no-op)。每步结果都入审计, + # 任一失败仍 fail-closed 退出(delivery 幂等可安全重投)。 + audit(f"event={ev} transition={t['id']} write-fail {e}——开始补偿回滚") + try: + swap_state(t["to_state"], t["from_state"]) + except WriteFail as e2: + audit(f"compensate 状态恢复失败 {e2}——人工置回 state:{t['from_state']}") + comp_id = f"{E.get('COMMENT_NODE_ID') or E.get('RUN_ID', 'run')}-compensate-release" + rc = adjudicate("/release", comp_id) + audit(f"compensate /release rc={rc}" + f"(0=租约已释放;非 0=TTL 到期自然回收或人工 /release)") raise out.write("invoke=none\n") audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " diff --git a/AGENTS.md b/AGENTS.md index fef2886..b558e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,9 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ## 常用命令(本仓驻留) - 校验本仓声明:`.github/workflows/gate.yml`(本地等价:`make gates-pr`——bash -n + yaml 全量解析) -- 漂移检测:`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑;§17=入口协议块对账) -- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级;`AUTOFIX_DRY_RUN=1` 只报告) -- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试) -- 漂移修复:`GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出)· 新仓初始化:`bash scripts/new-repo-init.sh ` +- 漂移检测/漂移修复/新仓初始化(**owner 或 CI 专属**——需 org admin PAT,agent 不得持此令牌,AG-1;agent 需要时提卡转交 owner 或走 workflow_dispatch):`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑;§17=入口协议块对账)· `GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出)· `bash scripts/new-repo-init.sh `(owner) +- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级;`AUTOFIX_DRY_RUN=1` 只报告;同上 owner/CI 专属) +- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试;同上 owner/CI 专属) - 取 App 令牌:`GH_TOKEN=$(scripts/ghcb )`(缓存命中零网络;`--refresh` 强刷,ADR-0044) - factory-floor 板/账本手动刷新:Actions → board-sync(dispatch-only;日常 cron 归 butler-ledger,ADR-0055) diff --git a/governance/board-sync.py b/governance/board-sync.py index fd93d32..62e8a8d 100644 --- a/governance/board-sync.py +++ b/governance/board-sync.py @@ -414,7 +414,8 @@ def main(): for key, entry in board.items(): if key in card_keys or entry.get("issue_state") != "CLOSED": continue - final = next((n[len("state:"):] for n in entry["labels"] + # 与 scan_cards 同判据:排序取首(closed 多标签时两投影确定性一致) + final = next((n[len("state:"):] for n in sorted(entry["labels"]) if n.startswith("state:")), None) if final and entry["fields"].get("State") != final and final in opt_ids: if DRY_RUN: diff --git a/governance/dashboard-update.py b/governance/dashboard-update.py index 3321912..db80eae 100644 --- a/governance/dashboard-update.py +++ b/governance/dashboard-update.py @@ -127,7 +127,7 @@ def scan_cards(repos): print(f"WARN multi-state {repo}#{it['number']}: {sl}" f"——多 state 标签并存,本轮取 {sl[0]},请修标签") cards.append({"repo": repo, "number": it["number"], "title": _safe_text(it["title"]), - "state": sl[0][len("state:"):], + "state": _safe_text(sl[0][len("state:"):]), "assignee": (it.get("assignees") or [{}])[0].get("login", ""), "url": it["html_url"], "updated_at": it.get("updated_at") or "", "days_idle": max(0, (NOW - _iso(it.get("updated_at"))).days)}) @@ -256,14 +256,16 @@ def ensure_issue(body): 查找范围 state=all(含已关闭:账本被人工关闭后复用之,不得重复创建—— 否则账本分裂、编辑历史散落);/issues 端点混入 PR,须按 "pull_request" - 键排除后再匹配标题。 + 键排除;标题不唯一——复用已存在账本还须带 `dashboard` label(本脚本创建 + 即打标;同名无标 issue 不接管,防 body 覆盖写进无关 issue)。 """ found = None page = 1 while True: batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=all&per_page=100&page={page}") found = next((i for i in batch - if "pull_request" not in i and i["title"] == ISSUE_TITLE), None) + if "pull_request" not in i and i["title"] == ISSUE_TITLE + and LABEL["name"] in [l.get("name") for l in i.get("labels", [])]), None) if found or len(batch) < 100: break page += 1 diff --git a/governance/drift-check.sh b/governance/drift-check.sh index da6e41b..b880c18 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -734,16 +734,22 @@ fi # entry_protocol: true 即纳入本节管辖。 PROTO_OK=0 PROTO_REPOS="" -# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。复用 §7 已生成的 -# REPO_MAP(python3+pyyaml→JSON);解析不可用或 entry_protocol 清单为空均 fail-closed。 -if [[ -n "${REPO_MAP:-}" ]]; then - PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$REPO_MAP") +# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。§17 自解析 +# (§7 同款 python3+pyyaml→JSON,不信任继承的 REPO_MAP——调用方 env 注入可 +# 重定向对账对象);解析不可用或 entry_protocol 清单为空均 fail-closed。 +PROTO_MAP="" +if python3 -c 'import yaml' 2>/dev/null; then + PROTO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' \ + "$DIR/REPOS.yaml" 2>/dev/null || true) +fi +if [[ -n "$PROTO_MAP" ]]; then + PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$PROTO_MAP") if [[ -z "$PROTO_REPOS" ]]; then drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" PROTO_OK=1 fi else - drift "REPOS.yaml 未解析(缺 python3+pyyaml,REPO_MAP 缺席)——协议块对账不可执行(fail-closed,ADR-0055 §17)" + drift "REPOS.yaml 未解析(缺 python3+pyyaml)——协议块对账不可执行(fail-closed,ADR-0055 §17)" PROTO_OK=1 fi proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空