From 9298a98cd58d92e0d52df7fa7e76388017a7ddfd Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:59:31 +0800 Subject: [PATCH] =?UTF-8?q?harness=EF=BC=9A=E5=BC=95=E6=93=8E=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=88=B0=20delivery-harness=20cdb69d8=EF=BC=88B46=20P?= =?UTF-8?q?2=20=E5=85=A8=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .harness/engine.lock | 4 +- .harness/engine/agents/dispatch.py | 55 ++- .harness/engine/agents/dispatch_host.py | 106 ++++- .../engine/agents/dispatch_observation.py | 15 +- .harness/engine/agents/review.py | 184 +++++++- .harness/engine/agents/run_timeline.py | 178 ++++++++ .harness/engine/checks/r1_checks.py | 17 +- .harness/engine/lang/swift.py | 94 +++- .harness/engine/prompts/dispatch_prompt.md | 14 + .harness/engine/routing/risk.py | 13 +- .harness/engine/routing/run_check.py | 422 +++++++++++++++++- 11 files changed, 1061 insertions(+), 41 deletions(-) create mode 100644 .harness/engine/agents/run_timeline.py diff --git a/.harness/engine.lock b/.harness/engine.lock index 17fba46..250c944 100644 --- a/.harness/engine.lock +++ b/.harness/engine.lock @@ -1,6 +1,6 @@ { "engine": "delivery-harness", "version": "0.1.0", - "commit": "2908a9aed239cc888d7e809e58ae7a211c96e6f4", - "tree": "sha256:4ad2d218b92a138c9309115e7edfd169891f1adac0bdb8983d6b677500647076" + "commit": "d253875b40fd2907f25732890727a092e63febcc", + "tree": "sha256:1ed5c4fd917aaf55c23baa290bd5fa3d24ee9ac57929f1f12d8d067e2c5d7f49" } diff --git a/.harness/engine/agents/dispatch.py b/.harness/engine/agents/dispatch.py index 98f0795..3db56dc 100644 --- a/.harness/engine/agents/dispatch.py +++ b/.harness/engine/agents/dispatch.py @@ -23,6 +23,7 @@ bin/dispatch stop --all bin/dispatch review [--reviewer opencode|pi|codex|claude-code] 独立评审(engine/agents/review.py) bin/dispatch review --pending | --watch [--interval 5] 评审全部待评审的 PR(后台常驻用 --watch) + bin/dispatch review-calibrate [--reviewer 名称] [--output 路径] 评审校准打分(不评论 PR) """ from __future__ import annotations @@ -40,7 +41,7 @@ from dataclasses import dataclass, field from pathlib import Path -from engine.agents import dispatch_host +from engine.agents import dispatch_host, run_timeline from engine.agents import dispatch_observation as observation from engine.checks import acceptance, taskbook from engine.core.common import ENGINE_DIR, ROOT, ci_workflows, git, load_rules, setting @@ -109,6 +110,10 @@ class Attempt: usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) executor_note: str = "" + # T202:最后一轮缺失报告的安全条目与状态(reported/unknown/invalid),插在 guard_allowed 前 + # (T105 冻结断言要求它仍是末位字段)。 + missing_context: list = field(default_factory=list) + missing_context_status: str = "unknown" guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) @@ -255,6 +260,18 @@ def render_prompt(task: Task, minutes: int, feedback: str) -> str: branch=task.branch, minutes=minutes, feedback=extra) +def _context_report(host, events: Path) -> dict: + """宿主的缺失上下文报告(B46 T202):宿主缺 parse_context 或解析失败时按 unknown 处理, + 不改变派发判定、退出码与调用序列(C0 失败隔离)。""" + parse = getattr(host, "parse_context", None) + if parse is None: + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + try: + return parse(events) + except Exception: # noqa: BLE001 报告解析失败不改变派发判定 + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + + def verify_signature(slot: Path, config: Config) -> tuple[bool, str, str, str]: """运行 verify;返回 (通过, 失败摘要, 失败签名, 完整输出)。签名去掉数字与路径,用于识别打转。""" result = subprocess.run(config.verify, cwd=slot, capture_output=True, text=True, check=False) @@ -309,8 +326,11 @@ def run_executor(self, task: Task, slot: Path, guard: Path, prompt: str, events: self.used_seconds += seconds model, usage, denials = self.host.parse(events) counts = observation.tool_counts(self.host, events) + context = _context_report(self.host, events) result = dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials, - counts["guard_allowed"]) + missing_context=context["items"], + missing_context_status=context["status"], + guard_allowed=counts["guard_allowed"]) observation.executor_round(task.branch, slot, prompt, events, result, {"name": self.host.name, "version": self.host.version()}, counts) return result @@ -332,10 +352,15 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace for key, value in result.guard_denials.items(): attempt.guard_denials[key] = attempt.guard_denials.get(key, 0) + value attempt.guard_allowed += result.guard_allowed + # 最后报告以最后一轮为准:每轮覆盖,抽取只读、不改变判定与退出码 + attempt.missing_context = result.missing_context + attempt.missing_context_status = result.missing_context_status note = slot / ESCALATION_FILE if note.exists(): attempt.executor_note = note.read_text(encoding="utf-8")[:4000] - observation.clarify(task.branch, note) + observation.clarify(task.branch, note, + context={"status": result.missing_context_status, + "items": result.missing_context}) attempt.exit = "clarify" return attempt, prompts if result.exit != "ok": @@ -387,11 +412,16 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr "failure_signatures": attempt.signatures, "ci_rounds_before": ci_rounds, "guard_denials": attempt.guard_denials, + "missing_context": attempt.missing_context, + "missing_context_status": attempt.missing_context_status, "gen_ai.usage.input_tokens": attempt.usage.get("input_tokens"), "gen_ai.usage.output_tokens": attempt.usage.get("output_tokens"), "cost": attempt.usage.get("cost"), "escalation": None if attempt.ok else attempt.exit, } + # T201 时间线:落盘前取本机链头,stages/anchors 安全摘要写进记录;记录提交后固定 run_record 锚点。 + timeline, head = run_timeline.record_fields(task.branch) + record.update(timeline) (folder / f"{number}.json").write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n") paths = [str(folder / f"{number}.json"), str(folder / f"{number}.prompt.md")] git("add", "--", *paths, cwd=slot) @@ -402,6 +432,7 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr message += "Risk: R1\n" # 与执行方的提交一致,否则 R1 声明会被这次记录提交打断 subprocess.run(["git", "commit", "--quiet", "-m", message], cwd=slot, env={**os.environ, **self.identity}, check=True) + run_timeline.fix_anchor(task.branch, head) return record["prompt_sha256"] # ---- 主流程 ---- @@ -676,6 +707,18 @@ def stop_all(root: Path = ROOT, wait_seconds: float = 30) -> int: return 1 +def review_calibrate_command(args) -> int: + """review-calibrate 子命令:在真实历史样本上重跑独立评审并统计 TPR/TNR(不评论 PR)。""" + from engine.agents import review as review_module # review 依赖本模块,按需导入 + + target = args.output if args.output.is_absolute() else ROOT / args.output + try: + return review_module.review_calibrate(args.reviewer, review_module.CALIBRATION_SAMPLES, target) + except (TypeError, ValueError) as error: + print(f"校准样本清单有问题:{error}") + return 2 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) @@ -689,6 +732,10 @@ def main(argv: list[str] | None = None) -> int: review.add_argument("--pending", action="store_true", help="评审全部待评审且 CI 已通过的 PR,然后退出") review.add_argument("--watch", action="store_true", help="后台常驻:每隔 --interval 分钟评审一轮") review.add_argument("--interval", type=float, default=5, help="--watch 的间隔(分钟)") + cal = sub.add_parser("review-calibrate", help="评审校准:在真实历史样本上统计 TPR/TNR(不评论 PR)") + cal.add_argument("--reviewer", choices=["opencode", "pi", "codex", "claude-code"]) + cal.add_argument("--output", type=Path, default=Path("build/review/calibration-report.md"), + help="报告路径(相对仓库根,缺省 build/review/calibration-report.md)") sub.add_parser("status", help="查看槽位") stop = sub.add_parser("stop", help="停机:终止所有正在运行的执行方") stop.add_argument("--all", action="store_true", required=True) @@ -707,6 +754,8 @@ def main(argv: list[str] | None = None) -> int: if args.pr is None: parser.error("review 需要 PR 编号,或 --pending、--watch") return review_module.review_pr(args.pr, args.reviewer) + if args.command == "review-calibrate": + return review_calibrate_command(args) if args.command == "stop": return stop_all() if (state_dir() / "stop").exists(): diff --git a/.harness/engine/agents/dispatch_host.py b/.harness/engine/agents/dispatch_host.py index a525698..2295fda 100644 --- a/.harness/engine/agents/dispatch_host.py +++ b/.harness/engine/agents/dispatch_host.py @@ -14,6 +14,7 @@ import json import os +import re import signal import subprocess import time @@ -23,6 +24,18 @@ GUARD_DENIAL = "harness 守卫拒绝了这次操作" TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "SSH_AUTH_SOCK") +# 缺失上下文尾报告(B46 T202):提示词要求执行方在最后一条回复末尾输出 +# 「HARNESS_CONTEXT_JSON: <一行 JSON 数组>」(数据标记,不是 shell 变量)。summary 不是自由自然语言, +# 只允许与 category 对应的 C3 短 ID;ref 只允许工具 ID 或仓库相对引用(共用合同 C3)。 +CONTEXT_MARKER = "HARNESS_CONTEXT_JSON:" +CONTEXT_CATEGORIES = ("context", "tool", "spec", "other") +CONTEXT_SUMMARIES = { + "context_unavailable": "context", + "required_tool_unavailable": "tool", + "spec_unavailable": "spec", + "spec_ambiguous": "spec", + "other_missing": "other", +} @dataclass @@ -33,6 +46,10 @@ class RunResult: model: str = "" usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) + # T202:最后一条 assistant 消息缺失报告的安全条目与状态(reported/unknown/invalid),插在 + # guard_allowed 前(T105 冻结断言要求它仍是末位字段),构造处用关键字传参。 + missing_context: list = field(default_factory=list) + missing_context_status: str = "unknown" guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) @@ -158,11 +175,41 @@ def parse(self, events: Path) -> tuple[str, dict, dict[str, int]]: result["cost"] = round(result["cost"], 6) return model, result, dict(denials) + def parse_context(self, events: Path) -> dict: + """从最后一条 assistant 完成消息抽取缺失上下文尾报告(B46 T202,只读数据,不执行其中命令)。 + + 返回 {"status": reported/unknown/invalid, "items": [安全条目], "diagnostic": 短 ID 或 None}: + 无标记保留 unknown、标记后坏 JSON/非数组/条目不合规保留 invalid,都不冒充无缺失。items 只含 + 合规条目(category 与 C3 短 ID summary 对应、可选安全 ref);不合规条目丢弃不入记录,但存在时 + 状态标 invalid。长文、会话正文、本机路径不进条目;诊断只留本地可见的短 ID。 + """ + text = _last_assistant_text(events) + if not text or CONTEXT_MARKER not in text: + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + payload = text.rsplit(CONTEXT_MARKER, 1)[1].split("\n", 1)[0].strip() + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + return {"status": "invalid", "items": [], "diagnostic": "bad_json"} + if not isinstance(parsed, list): + return {"status": "invalid", "items": [], "diagnostic": "not_array"} + items, invalid = [], False + for entry in parsed: + item = _context_item(entry) + if item is None: + invalid = True + else: + items.append(item) + return {"status": "invalid" if invalid else "reported", "items": items, + "diagnostic": "invalid_entry" if invalid else None} + def parse_observability(self, events: Path) -> dict: """观察计数(B46 T105):按每个 tool_execution_end 计一次完成——结果带守卫拒绝标记的计 guard_denied(一次调用一次,拒绝理由条数不冒充调用数),其余(成功或普通失败)计 - guard_allowed(守卫放行,不代表工具成功)。missing_context 供 T202 填充,本任务恒为空列表。""" - counts = {"guard_allowed": 0, "guard_denied": 0, "missing_context": []} + guard_allowed(守卫放行,不代表工具成功)。missing_context 取 parse_context 的安全条目 + (B46 T202);返回恰此 3 键。""" + counts = {"guard_allowed": 0, "guard_denied": 0, + "missing_context": self.parse_context(events)["items"]} if not events.exists(): return counts for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): @@ -176,3 +223,58 @@ def parse_observability(self, events: Path) -> dict: text = "\n".join(part.get("text", "") for part in content if isinstance(part, dict)) counts["guard_denied" if GUARD_DENIAL in text else "guard_allowed"] += 1 return counts + + +def _last_assistant_text(events: Path) -> str | None: + """流中最后一条 assistant 完成消息的正文(text 部分拼接);流缺失或无 assistant 消息返回 None。 + + 只认 message_end 携带的最终权威消息,工具输出(tool_execution_end)不参与,伪造标记不采信。 + """ + if not events.exists(): + return None + text = None + for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + message = event.get("message") if event.get("type") == "message_end" else None + if isinstance(message, dict) and message.get("role") == "assistant": + text = _message_text(message) + return text + + +def _message_text(message: dict) -> str: + """assistant 消息正文:content 为字符串时原样,为数组时拼接 text 部分(其余部分如 thinking 不算)。""" + content = message.get("content") + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + return "\n".join(part.get("text", "") for part in content + if isinstance(part, dict) and part.get("type") == "text") + + +def _context_item(entry) -> dict | None: + """一条报告条目:只允许 category/summary/ref 三个键,summary 是与 category 对应的 C3 短 ID, + ref 可选且须为工具 ID 或安全相对引用;任一不满足返回 None(条目丢弃、报告标 invalid)。""" + if not isinstance(entry, dict) or set(entry) - {"category", "summary", "ref"}: + return None + category, summary, ref = entry.get("category"), entry.get("summary"), entry.get("ref") + if CONTEXT_SUMMARIES.get(summary) != category: + return None + item = {"category": category, "summary": summary} + if ref is not None: + if not _safe_context_ref(ref): + return None + item["ref"] = ref + return item + + +def _safe_context_ref(ref) -> bool: + """ref 只允许工具 ID 或仓库相对引用:≤120 字符、无换行/反斜杠、非绝对路径、无盘符、无 .. 穿越。""" + if not isinstance(ref, str) or not ref or len(ref) > 120 or "\n" in ref or "\r" in ref: + return False + if "\\" in ref or ref.startswith(("/", "~")) or re.match(r"(?i)^[a-z]:", ref): + return False + return ".." not in ref.split("/") diff --git a/.harness/engine/agents/dispatch_observation.py b/.harness/engine/agents/dispatch_observation.py index 9610dc7..f69f30a 100644 --- a/.harness/engine/agents/dispatch_observation.py +++ b/.harness/engine/agents/dispatch_observation.py @@ -165,17 +165,26 @@ def local_verify(branch: str, slot: Path, base: str, ok: bool, signature: str, l return -def clarify(branch: str, note: Path) -> None: - """执行方请求澄清:备注 sha256 与是否请求澄清;missing_context 条目数由 T202 填充(当前恒 0)。""" +def clarify(branch: str, note: Path, context: dict | None = None) -> None: + """执行方请求澄清:备注 sha256 与是否请求澄清;context 是 T202 的解析结果,只把安全条目的 + 计数与类别计数写进事件(与运行记录 missing_context 同源;无条目时与原形状一致)。""" try: if not events.enabled(): return data = note.read_bytes() except Exception: # noqa: BLE001 观察旁路 return + items = (context or {}).get("items") or [] + outputs: dict = {"requested": True, "missing_context": len(items)} + per_category: dict = {} + for item in items: + if isinstance(item, dict) and isinstance(item.get("category"), str): + per_category[item["category"]] = per_category.get(item["category"], 0) + 1 + outputs.update({f"missing_context.{category}": count + for category, count in sorted(per_category.items())}) _emit(STAGE, "clarify", "ok", trace_id=branch, inputs=[{"kind": "note", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}], - outputs={"requested": True, "missing_context": 0}) + outputs=outputs) def push_pr(branch: str, slot: Path, pr: int, record_path: str, body: str) -> None: diff --git a/.harness/engine/agents/review.py b/.harness/engine/agents/review.py index 7be9478..a72853a 100644 --- a/.harness/engine/agents/review.py +++ b/.harness/engine/agents/review.py @@ -1,7 +1,9 @@ """独立评审:R2 及以上的 PR 由非任务设计方的 Agent 评审,结论写回 PR 供用户参考(目标态设计 8.3,决定 5)。 分离规则:评审方不是该任务的设计方(任务书头部 designer;没有任务书时按提交的 Co-Authored-By 判断); -也不看执行方轨迹,只看任务书、PR 描述、diff 与证据包。默认评审方与各评审方的模型由 rules.toml [review] +也不看执行方轨迹,只看任务书、PR 描述、diff 与证据包。材料里的任务书在既有探测(run_check.scope) +未命中时,从 PR 标题与正文的 `docs/plans/task-*.md` 引用回退(B68),探测命中时行为不变。 +默认评审方与各评审方的模型由 rules.toml [review] 指定(不配模型时沿用评审方工具自己的默认设置);评审方与执行方同一宿主时在评论中标出,供用户判断独立性。 结构上保证只读:评审在单独的 worktree(<仓库名>-review)里进行,Pi 只开放 read、grep、find、ls 且不加载 @@ -9,10 +11,13 @@ 凭据,结论由本脚本以 Agent 身份评论到 PR。 试行期内评审结论只是给用户的输入,不替代用户审批。 -校准(设计 8.3):`calibrate` 用已知有问题的改动(由事故回放用例在 main 上重新注入)与已知良好的已合并 PR, -分别统计抓住问题的比例(TPR)与放过好改动的比例(TNR),结果写入 evals/review/results/。 +校准(设计 8.3)有两个入口:`calibrate` 用已知有问题的改动(由事故回放用例在 main 上重新注入)与 +已知良好的已合并 PR,统计抓住问题的比例(TPR)与放过好改动的比例(TNR),结果写入 evals/review/results/; +`review_calibrate`(B59)对 docs/review/calibration/samples.json 里的真实历史样本(pr + head + 期望结论)逐个重跑 +独立评审,统计 TPR/TNR 与逐样本偏差,报告写本地 markdown,不评论任何 GitHub PR。 bin/dispatch review [--reviewer pi|codex|claude-code] + bin/dispatch review-calibrate [--reviewer 名称] [--output 路径] 评审校准打分(B59,不评论 PR) bin/harness review calibrate --reviewer pi [--limit N] [--jobs N] [--resume evals/review/results/<文件>] """ @@ -22,6 +27,7 @@ import datetime as dt import json import os +import re import subprocess import sys import tempfile @@ -39,6 +45,7 @@ PROMPT = ENGINE_DIR / "prompts" / "review_prompt.md" EVALS = ROOT / "evals" / "review" +CALIBRATION_SAMPLES = ROOT / "docs" / "review" / "calibration" / "samples.json" VERDICTS = ("通过", "不通过", "需用户验收") OTHER = {"claude-code": "codex", "codex": "claude-code"} REVIEWERS = ("opencode", "pi", "codex", "claude-code") @@ -303,6 +310,20 @@ def ci_summary(number: int, github) -> str: return "\n".join(f"- {item['name']}:{item['state']}({item['link']})" for item in checks) or "没有 CI 检查。" +def taskbook_from_pr_text(base: str, title: str, body: str, workspace: Path) -> tuple[str, str]: + """B68 回退:既有任务书探测(run_check.scope)未命中时,从 PR 标题与正文提取 `docs/plans/task-*.md` + 引用;引用文件在 base 上存在且 head 工作区仍可读时,返回 (相对路径, 全文)(与探测成功同一读取方式), + 否则返回 ("", "")(task.md 维持「无」)。只读 base 与工作区,不影响 designer_of 与评审只读性。""" + for rel in dict.fromkeys(re.findall(r"docs/plans/task-[\w.-]+\.md", f"{title}\n{body}")): + if not git("show", f"{base}:{rel}", cwd=workspace, check=False): + continue # base 上没有这份任务书 + try: + return rel, (workspace / rel).read_text(encoding="utf-8") + except OSError: + continue # head 上已被本 PR 删除:task_v1 无法按 路径@head 复取,视为未命中 + return "", "" + + def write_materials(workspace: Path, base: str, pr_text: str, task_text: str, ci_text: str = "") -> None: folder = workspace / "build" / "review" folder.mkdir(parents=True, exist_ok=True) @@ -376,9 +397,11 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= return 2 target = run_check.scope(base, "HEAD", pr["headRefName"], workspace) task_text = (workspace / target.taskbook).read_text(encoding="utf-8") if target and target.taskbook else "" + taskbook_rel = target.taskbook if target and target.taskbook else "" + if not taskbook_rel: # 探测未命中才回退,命中时行为逐字不变 + taskbook_rel, task_text = taskbook_from_pr_text(base, pr["title"], pr["body"] or "", workspace) write_materials(workspace, base, f"# {pr['title']}\n\n{pr['body']}", task_text, ci_summary(number, github)) - materials = observation.review_materials( - workspace, base, head, target.taskbook if target and target.taskbook else None, number) + materials = observation.review_materials(workspace, base, head, taskbook_rel or None, number) executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} independent = None if designer is None else name != designer verdict, model, seconds = run_reviewer(make_reviewer(name), workspace, load_rules().get("review", {}).get( @@ -601,6 +624,157 @@ def score(results: list[dict]) -> dict: } +# ---- 评审校准(B59:真实历史样本) ---- + +def load_samples(samples_path: Path) -> list[dict]: + """装载校准样本清单:每条必须有 pr(正整数)、head(40 位提交号)、expected(合法结论)与一句 reason。""" + try: + entries = json.loads(samples_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"校准样本清单读不到或不是合法 JSON:{samples_path}({error})") from error + if not isinstance(entries, list) or not entries: + raise ValueError(f"校准样本清单须是非空的样本数组:{samples_path}") + for index, entry in enumerate(entries, 1): + where = f"校准样本第 {index} 条" + if not isinstance(entry, dict): + raise TypeError(f"{where} 不是对象") + pr, head = entry.get("pr"), entry.get("head") + expected, reason = entry.get("expected"), entry.get("reason") + if isinstance(pr, bool) or not isinstance(pr, int) or pr <= 0: + raise ValueError(f"{where} 缺少合法的 pr 号(正整数),得到 {pr!r}") + if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{40}", head): + raise ValueError(f"{where}(PR #{pr})的 head 必须是 40 位十六进制提交号,得到 {head!r}") + if expected not in VERDICTS: + raise ValueError(f"{where}(PR #{pr})的 expected 只能是 {'、'.join(VERDICTS)},得到 {expected!r}") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"{where}(PR #{pr})缺少一句裁决依据(reason)") + return entries + + +def prepare_head_sample(workspace: Path, sample: dict) -> tuple[str, str]: + """把评审工作区检出到样本 head,返回 (base, PR 文本),材料组装与 review_pr 同一套(write_materials)。 + + 样本 head 不在 main 上时(中间轮次的 head)从 pull//head 取,对象不可得即报错(该样本进 errors)。 + base 与 review_base 同口径:已合并的样本用合并提交的第一父提交,未合并的退回与 origin/main 的合并基。 + """ + pr, head = sample["pr"], sample["head"] + git("fetch", "--quiet", "origin", f"pull/{pr}/head", cwd=workspace, check=False) + if not git("rev-parse", "--verify", "--quiet", f"{head}^{{commit}}", cwd=workspace, check=False): + raise ValueError(f"样本 head {head[:12]} 检出失败:origin/main 与 pull/{pr}/head 里都没有这个提交") + checkout(workspace, head) + merge = git("log", "--merges", "--first-parent", "--format=%H %s", "origin/main", cwd=workspace) + sha = next((line.split()[0] for line in merge.splitlines() if f"#{pr} " in f"{line} "), None) + if sha: + base = git("rev-parse", f"{sha}^1", cwd=workspace) + body = git("log", "-1", "--format=%B", sha, cwd=workspace) + else: + base = git("merge-base", "origin/main", "HEAD", cwd=workspace) + body = git("log", "-1", "--format=%B", head, cwd=workspace) + return base, f"# PR #{pr}\n\n{body}\n" + + +def sample_deviates(item: dict) -> bool: + """与期望不一致:期望不通过却放行、期望通过却抓中;期望需用户验收时,给了无阻断/严重发现的明确通过也算偏差。""" + if item["expected"] == "不通过": + return not item["flagged"] + if item["expected"] == "通过": + return item["flagged"] + return item["verdict"] != "需用户验收" and not item["flagged"] + + +def score_samples(results: list[dict]) -> dict: + """TPR=期望不通过的样本里抓中的比例,TNR=期望通过的样本里放行的比例。样本检出失败(error)与评审方 + 失败(failure)都进 errors、不进分母;有输出但没有可用结论(unparsed)同样不进分母,单独计数。""" + scored = [item for item in results if "error" not in item and not item.get("failure") and item.get("parsed", True)] + bad = [item for item in scored if item["expected"] == "不通过"] + good = [item for item in scored if item["expected"] == "通过"] + return { + "total": len(results), "bad": len(bad), "good": len(good), + "caught": sum(item["flagged"] for item in bad), + "released": sum(not item["flagged"] for item in good), + "deviations": sum(sample_deviates(item) for item in scored), + "errors": sum(1 for item in results if "error" in item or item.get("failure")), + "unparsed": sum("error" not in item and not item.get("failure") and not item.get("parsed", True) + for item in results), + "tpr": round(sum(item["flagged"] for item in bad) / len(bad), 3) if bad else None, + "tnr": round(sum(not item["flagged"] for item in good) / len(good), 3) if good else None, + } + + +def _cell(text: str) -> str: + """表格单元格转义:竖线与换行会破坏 markdown 表格。""" + return text.replace("|", "\\|").replace("\n", " ") + + +def render_calibration_report(samples_path: Path, results: list[dict], summary: dict, reviewer: str, + model: str) -> str: + def ratio(part: int, whole: int) -> str: + return f"{part / whole:.3f}({part}/{whole})" if whole else "n/a(分母 0)" + + lines = [ + "# 评审校准报告", "", + f"- 样本清单:{samples_path}", + f"- 评审方:{reviewer}{'(' + model + ')' if model else ''};日期:{dt.date.today().isoformat()}", + f"- TPR(期望不通过的抓中率):{ratio(summary['caught'], summary['bad'])}", + f"- TNR(期望通过的放行率):{ratio(summary['released'], summary['good'])}", + f"- 偏差 {summary['deviations']} 条;errors {summary['errors']} 条、unparsed {summary['unparsed']} 条不进分母", + "", "## 逐样本结果", "", + "| PR | head | 期望 | 实际结论 | 偏差 | 裁决依据 |", "|---|---|---|---|---|---|", + ] + for item in results: + if "error" in item: + actual, deviation = f"样本错误:{_cell(item['error'])}", "未计分" + elif item.get("failure"): + actual, deviation = f"评审方失败:{_cell(item['failure'])}", "未计分" + else: + actual = _cell(item["verdict"]) + ("(抓住)" if item["flagged"] else "") + deviation = "是" if sample_deviates(item) else "—" + lines.append(f"| #{item['pr']} | {item['head'][:12]} | {item['expected']} | {actual} | {deviation} | " + f"{_cell(item['reason'])} |") + lines += ["", "## 结论", "", + ("- 校准是测量工具:本报告只给出 TPR/TNR 与逐样本偏差,不改原判定、不评论任何 PR;" + "换评审方或模型后应在同一清单上重跑再比较。")] + return "\n".join(lines) + "\n" + + +def review_calibrate(review_name: str | None, samples_path: Path, output: Path, *, root: Path = ROOT, + reviewer: Reviewer | None = None) -> int: + """B59 评审校准:对真实历史样本(pr + head + 期望结论)逐个重跑独立评审,统计 TPR/TNR 与逐样本偏差, + 报告写本地 markdown(不评论到任何 GitHub PR)。样本检出失败与评审方失败进 errors,不进 TPR/TNR 分母。""" + samples = load_samples(samples_path) + name = review_name or load_rules().get("review", {}).get("reviewer") + runner = reviewer or (make_reviewer(name) if name else None) + if runner is None: + print("未能判定评审方:用 --reviewer 指定,或在 rules.toml [review] 配置 reviewer") + return 2 + workspace = review_workspace(root) + git("fetch", "--quiet", "origin", "main", cwd=workspace) + results = [] + for sample in samples: + item = {"pr": sample["pr"], "head": sample["head"], "expected": sample["expected"], + "reason": sample["reason"]} + try: + base, pr_text = prepare_head_sample(workspace, sample) + write_materials(workspace, base, pr_text, "") + except (ValueError, RuntimeError, subprocess.CalledProcessError) as error: + results.append({**item, "error": str(error)}) + print(f"PR #{sample['pr']} @ {sample['head'][:12]}:样本错误({error})", flush=True) + continue + verdict, model, seconds = run_reviewer(runner, workspace, load_rules().get("review", {}).get( + "timeout_minutes", 30) * 60) + results.append({**item, "verdict": verdict.verdict, "flagged": verdict.flagged, "parsed": verdict.parsed, + "failure": verdict.failure, "model": model, "seconds": round(seconds)}) + print(f"PR #{sample['pr']} @ {sample['head'][:12]}:{verdict.verdict}{'(抓住)' if verdict.flagged else ''}" + f"{':' + verdict.failure if verdict.failure else ''}", flush=True) + summary = score_samples(results) + model = next((item["model"] for item in results if item.get("model")), "") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(render_calibration_report(samples_path, results, summary, runner.name, model), encoding="utf-8") + print(f"校准完成:TPR {summary['tpr']}、TNR {summary['tnr']}、偏差 {summary['deviations']} 条、" + f"errors {summary['errors']} 条;报告已写入 {output}") + return 0 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) diff --git a/.harness/engine/agents/run_timeline.py b/.harness/engine/agents/run_timeline.py new file mode 100644 index 0000000..c4ad6ea --- /dev/null +++ b/.harness/engine/agents/run_timeline.py @@ -0,0 +1,178 @@ +"""运行记录时间线与固定链头(B46 T201):把本机事件链组装成运行记录里的安全摘要(共用合同 C3)。 + +职责边界(任务书 F1、共用合同 C0):事件由 dispatch_observation(T105)写入,本模块只读——从本机库 +取该 trace 已发生的事件,组装 stages/anchors 摘要。dispatch.write_record 在记录落盘前调用 record_fields +取链头与摘要,写入摘要并提交后用 fix_anchor 固定 run_record 锚点;锚点指向已发生事件的前缀,不含本次 +尚未发生的 push_pr/ci_wait,也不为补时间线追加推送。本模块不判定退出/预算/推送,读取或组装失败一律 +退化为空摘要(锚点为空),不改变派发的判定、返回码与 gh 调用序列。 + +stages 覆盖任务准入至本地检查的各已发生阶段(含更早尝试已发生的 push_pr/ci_wait),每项含 stage/step/ +status/ts/duration_ms/attempt/round/inputs/outputs/decision/actor/source/head_hash;attempt/round 按链内 +步骤推导(executor_round 计轮,ci_wait/escalate 之后进入下一次尝试)。decision.reason 只用稳定结果短 ID +(事件状态或既有 Attempt 退出词),原始理由正文以 sha256 留在 outputs;字符串沿用 emit 的隐私规则 +(超长、形似本机路径、含换行一律丢弃),记录不含原始事件流、完整日志或会话。 +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import re + +from engine.core import events, events_db + +_SOURCE = "local" # 记录时间线只取本机来源链;CI/GitHub 事实由各自来源记录 +_ANCHOR_STAGE = "dispatch" # 锚点在派发写记录时固定 +_FIXED_IN = "run_record" +_REASON_HASH_KEY = "decision.reason.sha256" # 原始理由正文的哈希(正文本身不入记录) +_EVENT_COLUMNS = ("id", "hash", "ts", "stage", "step", "status", "duration_ms", "actor_role", + "actor_host", "model", "decision_by", "decision_rule", "decision_reason", + "outputs", "source") +_REF_KEYS = ("kind", "ref", "sha256") +_MAX_STR = 120 +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") +_FORBIDDEN_PREFIXES = ("/Users/", "/home/") +# decision.reason 的稳定结果短 ID:C0 状态之外可用既有 Attempt 退出词(C3 词表,不造同义短 token)。 +_EXIT_WORDS = frozenset(("timeout", "stall", "stopped", "error", "loop", "retries", "clarify")) +_ROUND_STEPS = frozenset(("executor_round", "local_verify")) +# 尝试边界:CI 等待与升级都意味着上一次尝试已结束,其后的事件属于下一次尝试。 +_ATTEMPT_BOUNDARIES = frozenset(("ci_wait", "escalate")) + + +def record_fields(trace_id: str) -> tuple[dict, str | None]: + """记录的新增摘要字段(trace_id/stages/anchors)与记录落盘前的本机链头。 + + 链头必须在记录落盘前读取:锚点指向已发生事件的前缀,不含尚未发生的 push_pr/ci_wait。 + 读取失败或尚无事件时 stages/anchors 为空列表、链头为 None(锚点为空),原派发行为不变。 + """ + stages, head = _read_stages(trace_id) + anchors = [{"source": _SOURCE, "stage": _ANCHOR_STAGE, "head_hash": head, "fixed_in": _FIXED_IN}] if head else [] + return {"trace_id": trace_id, "stages": stages, "anchors": anchors}, head + + +def fix_anchor(trace_id: str, head_hash: str | None) -> None: + """记录落盘并提交后,把落盘前链头固定为 run_record 锚点;没有链头(无事件或库不可用)不登记。""" + if head_hash: + events.set_anchor(trace_id, _ANCHOR_STAGE, head_hash, _FIXED_IN, _SOURCE) + + +def _read_stages(trace_id: str) -> tuple[list[dict], str | None]: + """读取 (local, trace) 的已发生事件并按链序组装摘要;任何失败返回空摘要与 None 链头(观察旁路)。""" + try: + rows, refs = _read_events(trace_id) + except Exception: # noqa: BLE001 观察旁路:摘要组装失败不得影响派发 + return [], None + items: list[dict] = [] + attempt, round_no = 1, 0 + for row in rows: + if row["step"] == "executor_round": + round_no += 1 + outputs = _outputs(row) + items.append({ + "stage": row["stage"], "step": row["step"], "status": row["status"], "ts": row["ts"], + "duration_ms": row["duration_ms"], + "attempt": attempt, "round": round_no if row["step"] in _ROUND_STEPS else 0, + "inputs": refs.get(row["id"], []), "outputs": outputs, + "decision": _decision(row, outputs), "actor": _actor(row), + "source": row["source"], "head_hash": row["hash"], # 各阶段结束时的链头前缀 + }) + if row["step"] in _ATTEMPT_BOUNDARIES: + attempt, round_no = attempt + 1, 0 + return items, (rows[-1]["hash"] if rows else None) + + +def _read_events(trace_id: str) -> tuple[list[dict], dict[int, list[dict]]]: + """按 seq 读出本机链上的事件与输入引用;库缺失抛给调用方按空摘要处理。 + + 连接沿用 events_db 的连接参数(busy_timeout=5000):库锁竞争或恢复窗口内等待而非立即失败(B69)。 + """ + path = events_db.db_path() + if path is None or not path.exists(): + return [], {} + with contextlib.closing(events_db._connect(path)) as conn: + rows = [dict(zip(_EVENT_COLUMNS, values)) for values in conn.execute( + f"SELECT {','.join(_EVENT_COLUMNS)} FROM events WHERE source=? AND trace_id=? ORDER BY seq", + (_SOURCE, trace_id))] + refs: dict[int, list[dict]] = {} + joined = ("SELECT refs.event_id, refs.kind, refs.ref, refs.sha256, refs.size FROM refs" + " JOIN events ON refs.event_id = events.id" + " WHERE events.source=? AND events.trace_id=? AND refs.direction='in'" + " ORDER BY refs.event_id, refs.rowid") + for event_id, kind, ref, sha256, size in conn.execute(joined, (_SOURCE, trace_id)): + entry = _ref(kind, ref, sha256, size) + if entry: + refs.setdefault(event_id, []).append(entry) + return rows, refs + + +def _clean_str(value: str, limit: int) -> str | None: + """沿用 emit 的字符串隐私规则:超长、形似本机路径、含换行的丢弃,不截断、不改写。""" + if len(value) > limit or value.startswith(_FORBIDDEN_PREFIXES) or "C:\\" in value \ + or "\n" in value or "\r" in value: + return None + return value + + +def _ref(kind, ref, sha256, size) -> dict | None: + """一条输入引用:字符串键按隐私规则清洗,任一不合规整条丢弃(不保留半条引用)。""" + entry: dict = {} + for key, value in zip(_REF_KEYS, (kind, ref, sha256)): + if value is None: + continue + cleaned = _clean_str(str(value), _MAX_STR) + if cleaned is None: + return None + entry[key] = cleaned + if isinstance(size, int) and not isinstance(size, bool): + entry["size"] = size + return entry or None + + +def _outputs(row: dict) -> dict: + """outputs 列还原为扁平字典并按隐私规则重过滤;解析失败或形状不对按空处理。""" + try: + parsed = json.loads(row["outputs"] or "{}") + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(parsed, dict): + return {} + clean: dict = {} + for key, value in parsed.items(): + if not isinstance(key, str) or len(key) > _MAX_STR or not _KEY_RE.match(key): + continue + if value is None or isinstance(value, (bool, int, float)): + clean[key] = value + elif isinstance(value, str) and (cleaned := _clean_str(value, _MAX_STR)) is not None: + clean[key] = cleaned + return clean + + +def _decision(row: dict, outputs: dict) -> dict | None: + """决定摘要:by/rule 取事件原值,reason 只留稳定结果短 ID;原始理由正文以哈希留在 outputs。 + + 非成功的 executor_round 优先用 outputs 里的 Attempt 退出词(timeout/stall/…),其余用事件状态; + 都属于 C3 固定词表,不把任意理由正文塞回记录,也不重做判定。 + """ + by, rule, reason = row["decision_by"], row["decision_rule"], row["decision_reason"] + if by is None and rule is None and reason is None: + return None + short = row["status"] + if short != "ok" and row["step"] == "executor_round" and outputs.get("exit") in _EXIT_WORDS: + short = outputs["exit"] + if isinstance(reason, str) and reason and reason != short: + outputs[_REASON_HASH_KEY] = hashlib.sha256(reason.encode("utf-8")).hexdigest() + return {"by": by, "rule": rule, "reason": short} + + +def _actor(row: dict) -> dict | None: + """角色摘要:role/host/model 取事件原值并按隐私规则清洗,全空时为 None。""" + actor: dict = {} + for column, key in (("actor_role", "role"), ("actor_host", "host"), ("model", "model")): + value = row[column] + if value is None: + continue + cleaned = _clean_str(str(value), _MAX_STR) + if cleaned is not None: + actor[key] = cleaned + return actor or None diff --git a/.harness/engine/checks/r1_checks.py b/.harness/engine/checks/r1_checks.py index 3c89026..64bc5b2 100644 --- a/.harness/engine/checks/r1_checks.py +++ b/.harness/engine/checks/r1_checks.py @@ -7,6 +7,9 @@ - 不超规模阈值:增删行数不超过 .harness/config/autonomy.toml [size] max_lines 已有测试零改动、黄金快照零差异由 risk.py 的标记核对;变异得分不降由 build 的 harness job 在声明 R1 时运行 `mutate.py --check --changed-since`(要执行代码,放在 PR 自己的 CI 里,下降即失败)。 + +base 引用不存在(空仓库首个 PR、fresh clone 还没有 origin/main)时各项判定无法核验:判定入口返回 +「无法核验」的明确理由而不抛 git 错误,risk.py 接到后按既有降级口径取 R2。 """ from __future__ import annotations @@ -67,8 +70,20 @@ def changed_line_count(base: str, head: str, cwd: Path, exclude: list[str]) -> i return total +def base_missing(base: str, cwd: Path = ROOT) -> bool: + """base 引用无法解析为提交(空仓库首个 PR、fresh clone 还没有 origin/main)。""" + return not git("rev-parse", "--verify", "--quiet", f"{base}^{{commit}}", cwd=cwd, check=False).strip() + + +def unverifiable_reason(base: str) -> str: + """base 引用不存在时判定入口的统一理由;risk.py 接到后按既有降级口径取 R2。""" + return f"无法核验:base 引用 `{base}` 不存在" + + def violations(base: str, head: str, cwd: Path = ROOT, autonomy: dict | None = None) -> list[str]: - """返回不满足 R1 加强判定的理由;空列表表示满足。""" + """返回不满足 R1 加强判定的理由;空列表表示满足。base 引用不存在时返回「无法核验」理由,不抛 git 错误。""" + if base_missing(base, cwd): + return [unverifiable_reason(base)] autonomy = autonomy or load_autonomy() size = autonomy.get("size", {}) reasons = [] diff --git a/.harness/engine/lang/swift.py b/.harness/engine/lang/swift.py index 8c8e465..5f94fda 100644 --- a/.harness/engine/lang/swift.py +++ b/.harness/engine/lang/swift.py @@ -1,9 +1,11 @@ -"""Swift 语言插件(只用标准库的轻量解析)。 +r"""Swift 语言插件(只用标准库的轻量解析)。 函数体量与嵌套深度:先把注释(含嵌套块注释)与字符串字面量(普通、多行三引号、带 # 的原始字符串)抹成空格, -再配对大括号。「函数」指 func、init、deinit 与计算属性(`var x: T {`,SwiftUI 的 body 即此类);函数体行数从 -`{` 所在行数到匹配的 `}` 所在行,嵌套深度以函数体自身为 1,闭包与控制流都计入。解析不求完美(如 #if 分支、 -正则字面量不处理),只需对同一份代码稳定。签名按声明文本比对,重载按多个签名计;新增 import 即算新依赖。 +再配对大括号。字符串插值 `\(...)` 里的代码连同内嵌的字符串字面量一起抹平,不让内层引号提前终止外层字符串。 +「函数」指 func、init、deinit 与计算属性(`var x: T {`,SwiftUI 的 body 即此类);协议成员只有声明要求、 +没有实现体(如 `var x: T { get }`),不计入。函数体行数从 `{` 所在行数到匹配的 `}` 所在行,嵌套深度以函数体 +自身为 1,闭包与控制流都计入。解析不求完美(如 #if 分支、正则字面量不处理),只需对同一份代码稳定。 +签名按声明文本比对,重载按多个签名计;新增 import 即算新依赖。 """ from __future__ import annotations @@ -23,6 +25,67 @@ r"(?[^\s(<]+)|(?Pinit|deinit)\b[?!]?|var\s+(?P\w+)\s*:\s*[^=\n{};]+?\{)" ) _DECL_KEYWORD = re.compile(r"(? int: + r"""从 opening 之后扫描字符串字面量,返回结束位置(closing 之后)。 + + 识别插值 `\(...)` / `\#(...)`:插值内的嵌套字符串递归配对,不让内层引号提前终止外层字符串。 + 单行字符串未闭合时吞到行尾或文件尾(与既有行为一致)。 + """ + closing = quote + hashes + multiline = quote == '"""' + n = len(source) + j = start + while j < n: + ch = source[j] + if ch == "\\": + if source.startswith(hashes + "(", j + 1): + j = _interp_end(source, j + 2 + len(hashes), hashes, multiline) + continue + if not hashes: # 普通字符串的转义 `\x`;原始字符串没有转义 + j += 2 + continue + if source.startswith(closing, j): + return j + len(closing) + if ch == "\n" and not multiline: + return j + j += 1 + return n + + +def _interp_end(source: str, start: int, hashes: str, multiline: bool) -> int: + r"""扫描插值表达式,返回配对 `)` 之后的位置;插值内是代码上下文。 + + 圆括号计深度;字符串字面量(含插值,可递归)整体配对后跳过;单行字符串的插值遇到 + 换行视为未闭合,停在该换行处。 + """ + n = len(source) + depth = 1 + j = start + while j < n: + ch = source[j] + if ch == '"': + match = _STRING_START.match(source, j) + if match: + j = _string_end(source, match.end(), match.group(1), match.group(2)) + continue + elif ch == "#": + match = _STRING_START.match(source, j) + if match and match.group(1): # 插值里的原始字符串 `#"…"#` + j = _string_end(source, match.end(), match.group(1), match.group(2)) + continue + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return j + 1 + elif ch == "\n" and not multiline: + return j + j += 1 + return n def strip(source: str) -> str: @@ -54,19 +117,7 @@ def strip(source: str) -> str: continue match = _STRING_START.match(source, i) if match and (match.group(1) or ch == '"'): - hashes, quote = match.group(1), match.group(2) - closing = quote + hashes - j = match.end() - while j < n: - if not hashes and source[j] == "\\": - j += 2 - continue - if source.startswith(closing, j): - j += len(closing) - break - if quote == '"' and source[j] == "\n": - break - j += 1 + j = _string_end(source, match.end(), match.group(1), match.group(2)) out.append("".join("\n" if c == "\n" else " " for c in source[i:j])) i = j continue @@ -94,7 +145,7 @@ def _body_open(text: str, index: int) -> int | None: def functions(source: str) -> list[tuple[str, int, int, int]]: - """返回 (名字, 声明行号, 函数体行数, 最大嵌套深度)。""" + """返回 (名字, 声明行号, 函数体行数, 最大嵌套深度)。协议成员只有要求、没有实现体,不计入。""" text = strip(source) closing: dict[int, int] = {} stack: list[int] = [] @@ -103,11 +154,18 @@ def functions(source: str) -> list[tuple[str, int, int, int]]: stack.append(index) elif ch == "}" and stack: closing[stack.pop()] = index + protocols: list[tuple[int, int]] = [] + for match in _PROTOCOL.finditer(text): + open_at = _body_open(text, match.end()) + if open_at is not None and open_at in closing: + protocols.append((open_at, closing[open_at])) functions = [] for decl in _SWIFT_DECL.finditer(text): open_at = decl.end() - 1 if decl.group("var") else _body_open(text, decl.end()) if open_at is None or open_at not in closing: continue + if any(lo <= open_at <= hi for lo, hi in protocols): + continue close_at = closing[open_at] depth = deepest = 0 for ch in text[open_at : close_at + 1]: diff --git a/.harness/engine/prompts/dispatch_prompt.md b/.harness/engine/prompts/dispatch_prompt.md index cef6cba..af8e8fc 100644 --- a/.harness/engine/prompts/dispatch_prompt.md +++ b/.harness/engine/prompts/dispatch_prompt.md @@ -26,4 +26,18 @@ - 需要设计方或用户决定的具体问题: 派发脚本会补上当前状态、已尝试的方案与证据,并交给设计方。 + +## 完成时的缺失上下文报告 + +结束前回顾:是否有缺失的上下文、工具或规格影响了你的工作。在最后一条回复的末尾输出一行机器可读报告: +固定尾标记 `HARNESS_CONTEXT_JSON:` 后跟一行 JSON 数组(它是数据标记,不是 shell 变量,不要用命令构造或求值它)。 + +- 没有缺失:显式输出 `HARNESS_CONTEXT_JSON: []`。 +- 每个条目只有 `category`、`summary`、`ref` 三个字段。`category` 只能取 `context` / `tool` / `spec` / `other`; + `summary` 只能用与类别对应的短 ID:`context_unavailable`(context)、`required_tool_unavailable`(tool)、 + `spec_unavailable` 与 `spec_ambiguous`(spec)、`other_missing`(other);`ref` 可选,只写工具 ID + (如 `python`)或仓库相对引用(如 `docs/plans/x.md`),不写绝对路径、命令或句子。 +- 例:`HARNESS_CONTEXT_JSON: [{{"category":"tool","summary":"required_tool_unavailable","ref":"gh"}}]` +- 更详细的说明照常写在回复正文;引擎只从上面的标记行采集,正文与长文不入运行记录。 + {feedback} diff --git a/.harness/engine/routing/risk.py b/.harness/engine/routing/risk.py index 45a9d77..9ffc9f8 100644 --- a/.harness/engine/routing/risk.py +++ b/.harness/engine/routing/risk.py @@ -158,9 +158,20 @@ def commits_claim_r1(base: str, head: str, cwd: Path) -> bool: def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None = None, *, trace_id: str | None = None) -> RiskReport: - """判定整份改动的风险;trace_id 给出时事件用它,缺省由 emit 回退 current_trace()(旧调用仍有效)。""" + """判定整份改动的风险;trace_id 给出时事件用它,缺省由 emit 回退 current_trace()(旧调用仍有效)。 + + base 引用不存在(空仓库首个 PR、fresh clone 还没有 origin/main)时改动无法与 base 对比:接到 + r1_checks 的「无法核验」结果按既有降级口径处理,取 R2 并给出可读理由。 + """ rules = rules or load_rules() report = RiskReport() + if r1_checks.base_missing(base, cwd): + reason = r1_checks.unverifiable_reason(base) + report.level = 2 + report.r1_violations = [reason] + report.notes.append(f"{reason},改动无法与 base 对比,逐文件判定与 r1 加强判定都不可用 → 按降级口径取 R2") + _emit_summary(report, base, head, trace_id) + return report for status, path in changed_files(base, head, cwd): file_risk, flag = classify_file(status, path, base, head, rules, cwd, trace_id=trace_id) report.files.append(file_risk) diff --git a/.harness/engine/routing/run_check.py b/.harness/engine/routing/run_check.py index 295d44b..d6a41e0 100644 --- a/.harness/engine/routing/run_check.py +++ b/.harness/engine/routing/run_check.py @@ -4,6 +4,8 @@ 机器再核对一遍,即使绕过派发脚本手工派发,缺记录或超预算也会被发现: 适用 分支是 task/<名字> 且 docs/plans/task-<名字>.md 存在,或任一提交带 `Task:`(实现某份任务书的 PR) + 记录内容 序号最大的一份记录全文递归检查命令正文、本机路径与会话正文(B38);命中即新增失败的 Finding, + 只写字段位置与规则名、不回显禁项,判定只依据记录自身内容、不读事件 自行实现 任务书就在本 PR 中新增或修改:设计方自己实现(用户 2026-09-28 决定,方案 B),不经派发脚本, 不要求运行记录与 CI 轮次(这类 PR 本就不自动合并),仍要求每个提交带 `Task:` 归属 范围内每个非合并提交都带 `Task: <该任务书的编号>` @@ -21,6 +23,7 @@ import argparse import hashlib import json +import math import os import re import subprocess @@ -28,7 +31,7 @@ from pathlib import Path from engine.checks import taskbook -from engine.core import events +from engine.core import events, shell_structure from engine.core.common import ROOT, changed_files, commit_field, git RECORD_FIELDS = ( @@ -96,19 +99,32 @@ def check_trailers(base: str, head: str, task_id: str, cwd: Path) -> Finding: return Finding("归属", True, f"全部提交带 `Task: {task_id}`") -def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> Finding: +def _records(base: str, head: str, target: Scope, cwd: Path) -> list[tuple[int, str]]: + """范围内尚存(未删除)的运行记录,按序号排序。""" stem = Path(target.taskbook).stem - records = sorted( + return sorted( (int(match["number"]), path) for status, path in changed_files(base, head, cwd) if status != "D" and (match := RECORD_PATH.match(path)) and match["stem"] == stem ) + + +def _load_record(head: str, path: str, cwd: Path) -> tuple[object, Exception | None]: + """读取 head 上的一份记录 JSON;损坏时返回 (None, 异常)。""" + try: + return json.loads(git("show", f"{head}:{path}", cwd=cwd)), None + except (json.JSONDecodeError, RuntimeError) as error: + return None, error + + +def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> Finding: + records = _records(base, head, target, cwd) if not records: + stem = Path(target.taskbook).stem return Finding("运行记录", False, f"缺运行记录:没有新增 `docs/runs/{stem}/<序号>.json`(未经 bin/dispatch?)") number, path = records[-1] - try: - record = json.loads(git("show", f"{head}:{path}", cwd=cwd)) - except (json.JSONDecodeError, RuntimeError) as error: + record, error = _load_record(head, path, cwd) + if error is not None: return Finding("运行记录", False, f"`{path}` 不是有效的 JSON({error})") missing = [key for key in RECORD_FIELDS if key not in record] if missing: @@ -131,6 +147,397 @@ def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> f"重试 {record['retries']} 次") +# ---- 记录内容检查(B38/T203,共用合同 C3 字段/值表的最小判定集)---- +# +# 先对所有键/值拒绝本机路径、换行、超长、会话/命令形态与 shell 结构,再按已知字段校验声明语法; +# 未知自由文本 fail-closed。guard_denials 的键只有与内置 guard 规则理由完全相等的旧摘要可保留 +# (即使理由提及命令或连接符),且该例外仍不能含路径或换行;例外集合是 shell_structure 固定理由 +# 常量与命令表字面量的静态清单(评审 #51:不迭代规则表拼装,与常量不同步时按主线代码修订)。检查只读 +# 记录内容,不读事件库。 + +_MAX_TEXT = 200 # 与 events 的 decision.reason 上限同档:更长的字符串按超长拒绝,不截断 +_MAX_PROBLEMS = 8 # 失败原因里最多列出的命中处数(超出只报总数,不回显内容) +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") +_HEX_RE = re.compile(r"[0-9a-f]{6,64}\Z") +_HEX64_RE = re.compile(r"[0-9a-f]{64}\Z") +_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@/?#=+-]*\Z") # 短 ID/模型/版本/分支/相对引用/URL +_PATTERN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@?*+-]*\Z") # kind=pattern 的规定通配模式 +_SEGMENT_RE = re.compile(r"[A-Za-z0-9.@_-]+\Z") +_TIMESTAMP_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\Z") +_NOSPACE_RE = re.compile(r"\S{1,200}\Z") +_CJK_TEXT_RE = re.compile(r"[\w #?=&/:.,@+()()!?,。:、;%*-]+\Z") +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_SESSION_RE = re.compile(r"(?i)\b(?:user|assistant|system|human)\s*[::]|(?:用户|助手)\s*[::]") +_EXECUTABLES = ("sh", "bash", "zsh", "python", "python3", "node", "npm", "npx", "pip", "curl", + "wget", "ssh", "scp", "make", "rm", "ls", "cat", "git", "gh") +_COMMAND_PREFIX_RE = re.compile(r"(?i)\s*(?:" + "|".join(_EXECUTABLES) + r")\s+\S") +_BARE_COMMAND_RE = re.compile(r"(?i)\s*(?:" + "|".join(_EXECUTABLES) + r")\s*\Z") +_SHELL_META = ("|", ";", "`", ">", "<", "$(", "&&", "||") +_LOCAL_PATHS = ("/Users/", "/home/", "C:\\") + +# 可信守卫理由的静态清单(旧 guard_denials 摘要例外):shell_structure 的固定理由常量(含结构化 +# 规则专有的 OVERRIDE)加上命令表 clean_keep 兜底规则的独立字面量;COMMAND_RULES/TOOL_RULES 及 +# implementer 规则的取值与这些常量一致(评审 #51:静态并入,宁可少误拒;理由本身是引擎内置 +# 常量、非记录内容)。主线上是已批准代码,CI 侧原只报告。 +_TRUSTED_GUARD_REASONS = frozenset(( + shell_structure.FILTER, shell_structure.FORCE, shell_structure.FORCE_REFSPEC, + shell_structure.BATCH, shell_structure.PUSH_MAIN, shell_structure.PUSH_TAG, + shell_structure.TAG, shell_structure.UPDATE_REF, shell_structure.NO_VERIFY, + shell_structure.NO_VERIFY_SHORT, shell_structure.HOOKS_PATH, shell_structure.OVERRIDE, + shell_structure.RESET_HARD, shell_structure.RM_OUTSIDE, shell_structure.RELEASE, + shell_structure.ISSUE_DELETE, shell_structure.LABEL_ERASE, shell_structure.ISSUE_IMPLEMENTER, + shell_structure.DELETE_REMOTE, shell_structure.API_WRITE, shell_structure.MERGE, + shell_structure.APPROVE, + "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径", +)) +# CLEAN_X 的派生理由({paths} 以「-e 路径」清单填充):按模板前缀 + 填充形态精确匹配,不是 +# starts-with/contains 例外(C06)——追加任意正文不匹配。 +_CLEAN_X_DERIVED_RE = re.compile( + re.escape(shell_structure.CLEAN_X.split("{paths}")[0]) + r"-e [A-Za-z0-9.@_/-]+(?:、-e [A-Za-z0-9.@_/-]+)*\Z") +_K_CLASSES = frozenset(f"K{level}" for level in range(8)) +_EXIT_WORDS = frozenset(("ok", "timeout", "stall", "stopped", "error", "loop", "retries", "clarify")) +_ALERT_REASONS = frozenset(( + "integrity_failure", "review_rejected", "review_error", "dispatch_stall", "dispatch_timeout", + "dispatch_loop", "dispatch_budget", "ci_last_round", "guard_denials", "budget_exhausted", + "audit_missing_stage", "audit_anchor_mismatch", +)) +_DECISION_REASONS = frozenset(events.STATUSES) | _EXIT_WORDS | _ALERT_REASONS +_FIXED_IN_VALUES = frozenset(("run_record", "ci_artifact", "pr_comment")) +_MC_CATEGORIES = frozenset(("context", "tool", "spec", "other")) +_MC_SUMMARIES = frozenset(("context_unavailable", "required_tool_unavailable", "spec_unavailable", + "spec_ambiguous", "other_missing")) +_MC_STATUSES = frozenset(("reported", "unknown", "invalid")) + + +def _forbidden(text: str, *, length: bool = True) -> str | None: + """字符串的通用禁项(换行、本机路径、超长、会话正文、命令正文、shell 结构):返回规则名或 None。""" + if "\n" in text or "\r" in text: + return "换行" + if any(marker in text for marker in _LOCAL_PATHS): + return "本机路径" + if length and len(text) > _MAX_TEXT: + return "超长" + if _SESSION_RE.search(text): + return "会话正文" + if _COMMAND_PREFIX_RE.match(text): + return "命令正文" + if any(meta in text for meta in _SHELL_META): + return "Shell 结构" + return None + + +def _is_int(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_number(value) -> bool: + return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) + + +def _repo_relative(text: str) -> bool: + """仓库相对路径(可带 @提交):拒绝绝对路径、~ 展开与路径穿越。""" + if text.startswith(("/", "~")) or ":" in text: + return False + return all(segment and segment not in (".", "..") and _SEGMENT_RE.fullmatch(segment) + for segment in text.split("/")) + + +def _ref_form(value: str) -> bool: + """引用值:仓库相对路径/带提交路径、受支持 URL 或规定短 ID。""" + return _TOKEN_RE.fullmatch(value) is not None or _repo_relative(value) + + +def _safe_value(text: str) -> bool: + """摘要位置字符串的合法值形态:哈希、短 ID/引用/URL、无空白短词或含中文的安全摘要。""" + if _HEX_RE.fullmatch(text) or _TOKEN_RE.fullmatch(text) or _NOSPACE_RE.fullmatch(text): + return True + return bool(_CJK_RE.search(text) and _CJK_TEXT_RE.fullmatch(text)) + + +def _check_str(value, location, problems, ok, rule="字段形态") -> None: + """已知字符串字段:先通用禁项,后声明语法。""" + if not isinstance(value, str): + problems.append((location, "非法类型")) + return + if hit := _forbidden(value): + problems.append((location, hit)) + return + if not ok(value): + problems.append((location, rule)) + + +def _enum_factory(words): + def check(value, location, problems): + if not isinstance(value, str) or value not in words: + if isinstance(value, str) and (hit := _forbidden(value)): + problems.append((location, hit)) + else: + problems.append((location, "非法类型")) + return check + + +def _int_factory(minimum, *, none=False): + def check(value, location, problems): + if value is None and none: + return + if not _is_int(value) or value < minimum: + problems.append((location, "非法类型")) + return check + + +def _number_factory(*, none=False): + def check(value, location, problems): + if value is None and none: + return + if not _is_number(value): + problems.append((location, "非法类型")) + return check + + +def _none_or(check): + def wrapper(value, location, problems): + if value is not None: + check(value, location, problems) + return wrapper + + +def _p_token(value, location, problems) -> None: + _check_str(value, location, problems, _TOKEN_RE.fullmatch) + + +def _p_ts(value, location, problems) -> None: + _check_str(value, location, problems, _TIMESTAMP_RE.fullmatch) + + +def _p_ts_legacy(value, location, problems) -> None: + """旧记录的 started_at/ended_at:禁项必查,不按时间戳语法拒绝既有旧形态(评审 #51 G2 回归)。""" + _check_str(value, location, problems, lambda _text: True) + + +def _p_sha(value, location, problems) -> None: + _check_str(value, location, problems, _HEX64_RE.fullmatch) + + +def _p_path(value, location, problems) -> None: + _check_str(value, location, problems, _repo_relative) + + +def _p_signatures(value, location, problems) -> None: + """失败签名:检查名与哈希的短串,或派发固定的无空白问题描述。""" + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + _check_str(item, f"{location}[{index}]", problems, _NOSPACE_RE.fullmatch) + + +_p_size = _int_factory(0) + + +def _scan_struct(value, location, problems, fields: dict, *, exact_keys=False, required=()) -> None: + """结构化 dict:已知键按字段校验;未知键拒绝(在父位置报告,不回显键名);exact_keys 时缺键也拒绝。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key in (required or (fields if exact_keys else ())): + if key not in value: + problems.append((f"{location}.{key}", "缺字段")) + for key, item in value.items(): + check = fields.get(key) + if check is None: + problems.append((location, "未知字段")) + _scan_free(item, location, problems) + else: + check(item, f"{location}.{key}", problems) + + +def _scan_list(value, location, problems, fields, *, exact_keys=False, required=()) -> None: + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + _scan_struct(item, f"{location}[{index}]", problems, fields, exact_keys=exact_keys, required=required) + + +def _scan_free(value, location, problems) -> None: + """未知字段的任意 JSON 值:通用禁项逐键逐值生效;标量字符串再按自由文本值形态拒绝。""" + if isinstance(value, str): + if hit := _forbidden(value): + problems.append((location, hit)) + elif not _safe_value(value): + problems.append((location, "未知自由文本")) + elif isinstance(value, dict): + for key, item in value.items(): + hit = _forbidden(key, length=False) if isinstance(key, str) else None + if hit: + problems.append((location, hit)) + elif not isinstance(key, str) or not _KEY_RE.fullmatch(key): + problems.append((location, "键不合规")) + _scan_free(item, location, problems) + elif isinstance(value, list): + for index, item in enumerate(value): + _scan_free(item, f"{location}[{index}]", problems) + + +def _p_output_value(value, location, problems) -> None: + """outputs 标量值:数字/布尔/None 判型;字符串按摘要值形态,裸 shell 命令名也拒绝。""" + if value is None or isinstance(value, bool): + return + if isinstance(value, (int, float)): + if not math.isfinite(value): + problems.append((location, "非法类型")) + return + if not isinstance(value, str): + problems.append((location, "非法类型")) + return + if hit := _forbidden(value): + problems.append((location, hit)) + return + if _BARE_COMMAND_RE.fullmatch(value.strip()): + problems.append((location, "命令正文")) + return + if not _safe_value(value): + problems.append((location, "未知自由文本")) + + +def _p_outputs(value, location, problems) -> None: + """扁平 outputs:键是短标识,值是标量或安全形态字符串。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key, item in value.items(): + if not isinstance(key, str) or not _KEY_RE.fullmatch(key): + hit = _forbidden(key, length=False) if isinstance(key, str) else None + problems.append((location, hit or "键不合规")) + _scan_free(item, location, problems) + else: + _p_output_value(item, f"{location}.{key}", problems) + + +def _p_refs(value, location, problems) -> None: + """输入引用列表:kind/ref/sha256/size;kind=pattern 的 ref 允许规定通配模式。 + + 未知键的位置用固定占位(评审 #51 严重项):键名属记录内容,不得进入 Finding。 + """ + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + child = f"{location}[{index}]" + if not isinstance(item, dict): + problems.append((child, "非法类型")) + continue + for key, item_value in item.items(): + if key == "kind": + _p_token(item_value, f"{child}.kind", problems) + elif key == "ref": + is_pattern = item.get("kind") == "pattern" + _check_str(item_value, f"{child}.ref", problems, + _PATTERN_RE.fullmatch if is_pattern else _ref_form) + elif key == "sha256": + _p_sha(item_value, f"{child}.sha256", problems) + elif key == "size": + _p_size(item_value, f"{child}.size", problems) + else: + hidden = f"{child}.未知键(已隐去)" + problems.append((hidden, "未知字段")) + _scan_free(item_value, hidden, problems) + + +def _p_guard_denials(value, location, problems) -> None: + """守卫拒绝计数:键只有与内置规则理由完全相等的旧摘要可保留(不能含路径/换行),计数非负整数。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key, count in value.items(): + hit = _forbidden(key, length=False) + if hit in ("换行", "本机路径"): + problems.append((location, hit)) + elif key not in _TRUSTED_GUARD_REASONS and not _CLEAN_X_DERIVED_RE.fullmatch(key): + problems.append((location, "未知理由")) + elif not _is_int(count) or count < 0: + problems.append((location, "非法类型")) + + +_DECISION_FIELDS = {"by": _none_or(_p_token), "rule": _none_or(_p_token), + "reason": _none_or(_enum_factory(_DECISION_REASONS))} +_ACTOR_FIELDS = {"role": _enum_factory(frozenset(events.ACTOR_ROLES)), "host": _p_token, "model": _p_token} +_STAGE_FIELDS = { + "stage": _enum_factory(frozenset(events.STAGES)), "step": _p_token, + "status": _enum_factory(frozenset(events.STATUSES)), "ts": _p_ts, + "duration_ms": _int_factory(0, none=True), "attempt": _int_factory(1), "round": _int_factory(0), + "inputs": _p_refs, "outputs": _p_outputs, "decision": _none_or( + lambda value, location, problems: _scan_struct(value, location, problems, _DECISION_FIELDS, + exact_keys=True)), + "actor": _none_or(lambda value, location, problems: _scan_struct(value, location, problems, + _ACTOR_FIELDS)), + "source": _p_token, "head_hash": _p_sha, +} +_ANCHOR_FIELDS = {"source": _p_token, "stage": _enum_factory(frozenset(events.STAGES)), + "head_hash": _p_sha, "fixed_in": _enum_factory(_FIXED_IN_VALUES)} +_MC_FIELDS = {"category": _enum_factory(_MC_CATEGORIES), "summary": _enum_factory(_MC_SUMMARIES), + "ref": _p_token} +_TOP_FIELDS = { + "task": _p_token, "class": _enum_factory(_K_CLASSES), "attempt": _int_factory(1), + "branch": _p_token, "trace_id": _p_token, + "gen_ai.agent.name": _p_token, "host_version": _p_token, "gen_ai.request.model": _p_token, + "prompt_sha256": _p_sha, "prompt_path": _p_path, "guard_ref": _p_token, + "started_at": _p_ts_legacy, "ended_at": _p_ts_legacy, "executor_seconds": _number_factory(), + "exit": _enum_factory(_EXIT_WORDS), "retries": _int_factory(0), + "failure_signatures": _p_signatures, "ci_rounds_before": _int_factory(0), + "guard_denials": _p_guard_denials, + "gen_ai.usage.input_tokens": _int_factory(0, none=True), + "gen_ai.usage.output_tokens": _int_factory(0, none=True), + "cost": _number_factory(none=True), "escalation": _none_or(_enum_factory(_EXIT_WORDS)), + "stages": lambda value, location, problems: _scan_list(value, location, problems, _STAGE_FIELDS, + exact_keys=True), + "anchors": lambda value, location, problems: _scan_list(value, location, problems, _ANCHOR_FIELDS, + exact_keys=True), + "missing_context": lambda value, location, problems: _scan_list(value, location, problems, _MC_FIELDS, + required=("category", "summary")), + "missing_context_status": _enum_factory(_MC_STATUSES), +} + + +def scan_record(record) -> list[tuple[str, str]]: + """记录全文递归扫描(B38):返回 (字段位置, 规则名);只写位置与规则名,不回显禁项。""" + problems: list[tuple[str, str]] = [] + if not isinstance(record, dict): + return [("记录", "非法类型")] + for key, value in record.items(): + check = _TOP_FIELDS.get(key) + if check is None: + problems.append(("记录", "未知字段")) + _scan_free(value, "记录", problems) + else: + check(value, str(key), problems) + return problems + + +def check_record_content(base: str, head: str, target: Scope, cwd: Path = ROOT) -> Finding | None: + """记录内容检查(B38):记录里有命令正文、本机路径或会话正文即失败的 Finding。 + + 由 check() 在 check_record 的 JSON/身份/提示词哈希核对之后调用;记录缺失、JSON 无效或未命中时 + 返回 None——干净记录的 Finding 与事件序列与之前逐字相同,坏记录才多一条失败 Finding。 + 判定只依据记录自身内容(scan_record),不读事件库。 + """ + records = _records(base, head, target, cwd) + if not records: + return None + _number, path = records[-1] + record, error = _load_record(head, path, cwd) + if error is not None or not isinstance(record, dict): + return None + problems = scan_record(record) + if not problems: + return None + shown = problems[:_MAX_PROBLEMS] + detail = ";".join(f"{location}:{rule}" for location, rule in shown) + if len(problems) > len(shown): + detail += f"(共 {len(problems)} 处)" + return Finding("记录内容", False, f"`{path}` 命中内容禁项:{detail}") + + def ci_rounds(runs: list[dict]) -> int: """已完成的 CI 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" return len({run.get("headSha") or run.get("head_sha") for run in runs @@ -182,6 +589,9 @@ def check(base: str, head: str, branch: str, cwd: Path = ROOT, rounds: int | Non _record(target, findings) return findings findings.append(check_record(base, head, target, branch, cwd)) + content = check_record_content(base, head, target, cwd) + if content is not None: + findings.append(content) if with_ci: findings.append(check_ci(rounds, target.header)) _record(target, findings) # 观察:适用性与各项发现