From d815dce3f451bb918266731b80aadbcfa2334746 Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:11:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?tests=EF=BC=9A=E6=B6=88=E8=B4=B9=E6=96=B9?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=E5=AF=B9=E9=BD=90=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=80=A7=E6=8E=A5=E5=8F=A3=EF=BC=88=E9=9A=8F?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20PR=20=E5=90=88=E5=B9=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FakeGitHub.wait_ci 增加 detail 形参(T105 起 CI 失败摘要入参,带默认值双向兼容) - 三处 reviewer.read 解包改为 text, model, *_(兼容 2/3 元组) - 三个假评审器 read 返回补 basis 第三元(reported),匹配 T105 起 run_reviewer 的三态解包 注意:假评审器三元组仅兼容升级后的引擎,本分支须随 .harness 升级 PR 原子合并,不可单独先合。 --- .harness/engine.lock | 4 +- .harness/engine/agents/dispatch.py | 144 ++++++++++---- .harness/engine/agents/dispatch_host.py | 20 ++ .harness/engine/agents/review.py | 83 +++++++-- .harness/engine/checks/integrity.py | 87 ++++++++- .harness/engine/checks/verify.py | 52 +++++- .harness/engine/cli.py | 51 ++++- .harness/engine/core/common.py | 8 + .harness/engine/guards/command_guard.py | 228 +++++++++++++++++------ .harness/engine/guards/git_guard.py | 78 +++++--- .harness/engine/reports/metrics.py | 11 +- .harness/engine/reports/weekly.py | 8 +- .harness/engine/routing/policy.py | 87 ++++++++- .harness/engine/routing/risk.py | 61 ++++-- .harness/engine/routing/run_check.py | 8 +- tests/test_harness_dispatch.py | 2 +- tests/test_harness_review_independent.py | 12 +- 17 files changed, 753 insertions(+), 191 deletions(-) diff --git a/.harness/engine.lock b/.harness/engine.lock index 9af9978..424e824 100644 --- a/.harness/engine.lock +++ b/.harness/engine.lock @@ -1,6 +1,6 @@ { "engine": "delivery-harness", "version": "0.1.0", - "commit": "f7b79913a04206c2ff9452f540bcd1211096ff54", - "tree": "sha256:c5900fe320c95e8d98c2e2663d602bc629a1e5352add91647b29fed67ed32702" + "commit": "9ec57e4a9c3f91ee2270422b58f68cd46a479cdf", + "tree": "sha256:fde91eef9377fd94a4fc8d5cd2d5f5bf2a277d3282c5161d547d5f8c5b0b31e3" } diff --git a/.harness/engine/agents/dispatch.py b/.harness/engine/agents/dispatch.py index ab1a256..f467c9f 100644 --- a/.harness/engine/agents/dispatch.py +++ b/.harness/engine/agents/dispatch.py @@ -15,6 +15,8 @@ 不能完成时升级:已开 PR 的在 PR 上评论,否则开议题,都加 escalation 标签;升级包预填当前状态、 已尝试的方案与证据,执行方写的「可选方案」「需要决定的问题」(build/dispatch/escalation.md)附在后面。 运行记录只存结构化摘要,完整事件流留在本机 /dispatch/runs/,不入库。 +各步骤的实际结果另由 engine/agents/dispatch_observation.py 写观察事件(B46 T105):观察是旁路, +不改变本文件的判定、预算与返回码。 bin/dispatch run docs/plans/task-005-x.md [--resume] [--model M] bin/dispatch status @@ -39,11 +41,14 @@ from pathlib import Path from engine.agents import dispatch_host +from engine.agents import dispatch_observation as observation from engine.checks import taskbook -from engine.core.common import ENGINE_DIR, ROOT, git, load_rules, setting +from engine.core.common import ENGINE_DIR, ROOT, ci_workflows, git, load_rules, setting PROMPT_TEMPLATE = ENGINE_DIR / "prompts" / "dispatch_prompt.md" ESCALATION_FILE = "build/dispatch/escalation.md" +CI_QUERY_ATTEMPTS = 3 # 等 CI 时对 gh 查询的最多尝试次数 +CI_QUERY_RETRY_SECONDS = 5 def agent_identity() -> tuple[str, str]: @@ -104,6 +109,7 @@ class Attempt: usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) executor_note: str = "" + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) # ---- 1 准入与 2 认领 ---- @@ -112,20 +118,26 @@ def admit(path: str, root: Path = ROOT, require_on_main: bool = True) -> Task: rel = Path(path).as_posix().removeprefix("./") reports = [report for report in taskbook.check_all(root) if report.path == rel] if not reports: + observation.admit(rel, root, problems=[f"{rel} 不是 docs/plans/task-*.md 下的任务书"]) raise Stop(f"{rel} 不是 docs/plans/task-*.md 下的任务书") report = reports[0] if rel in taskbook.load_exempt(root / ".harness" / "state" / "taskbook-exempt.txt"): - raise Stop(f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发") + problem = f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发" + observation.admit(rel, root, problems=[problem]) + raise Stop(problem) if require_on_main: problem = taskbook.on_main(rel, root) if problem: report.errors.append(problem) if report.errors: + observation.admit(rel, root, problems=report.errors) raise Stop("准入未通过:\n" + "\n".join(f" - {error}" for error in report.errors)) header = report.header slug = Path(rel).stem.removeprefix("task-") - return Task(rel, header["task"], header["class"], header["risk"], header["budget"], + task = Task(rel, header["task"], header["class"], header["risk"], header["budget"], header.get("spec_refs") or [], f"task/{slug}") + observation.admit(rel, root, task=task) + return task # ---- 3 槽位 ---- @@ -174,6 +186,7 @@ def acquire_slot(root: Path, config: Config, task: Task) -> tuple[int, Path]: json.dump({"pid": os.getpid(), "task": task.id, "branch": task.branch, "started_at": _now()}, handle) return index, slot_path(root, config, index) + observation.slot(task.branch, None, problem=f"{config.slots} 个槽位都在使用中") raise Stop(f"{config.slots} 个槽位都在使用中:稍后再派发,或用 bin/dispatch status 查看") @@ -205,7 +218,7 @@ def prepare_slot(root: Path, slot: Path, branch: str, resume: bool) -> None: # ---- 4 守卫 ---- -def prepare_guard(root: Path) -> tuple[Path, str]: +def prepare_guard(root: Path, trace: str = "") -> tuple[Path, str]: """把 origin/main 上的守卫导出到本机目录并确认可运行;返回 (扩展路径, main 的提交)。""" ref = git("rev-parse", "origin/main", cwd=root) bundle = state_dir(root) / "guard" / ref @@ -218,6 +231,7 @@ def prepare_guard(root: Path) -> tuple[Path, str]: paths = ["harness", ".pi"] if legacy else [".harness", ".pi"] archive = subprocess.run(["git", "archive", ref, *paths], cwd=root, capture_output=True, check=False) if archive.returncode != 0: + observation.guard_preflight(trace, ref, False) raise Stop(f"无法从 origin/main 导出守卫:{archive.stderr.decode(errors='replace').strip()}") subprocess.run(["tar", "-x", "-C", str(bundle)], input=archive.stdout, check=True) extension = bundle / ".pi" / "extensions" / "harness-guard.ts" @@ -225,7 +239,9 @@ def prepare_guard(root: Path) -> tuple[Path, str]: [sys.executable, *guard_cmd, "--format", "json", "--role", "implementer"], input=json.dumps({"command": "gh issue close 1"}), capture_output=True, text=True, check=False, ) - if not extension.exists() or probe.returncode != 2: + denied = extension.exists() and probe.returncode == 2 + observation.guard_preflight(trace, ref, denied) + if not denied: raise Stop("守卫预检失败:导出的守卫没有拒绝必拒绝的样例,停止派发") return extension, ref @@ -239,24 +255,25 @@ def render_prompt(task: Task, minutes: int, feedback: str) -> str: branch=task.branch, minutes=minutes, feedback=extra) -def verify_signature(slot: Path, config: Config) -> tuple[bool, str, str]: - """运行 verify;返回 (通过, 失败摘要, 失败签名)。签名去掉数字与路径,用于识别打转。""" +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) + log = result.stdout + result.stderr if result.returncode == 0: - return True, "", "" + return True, "", "", log summary_file = slot / "build" / "verify" / "summary.json" failed, excerpts = [], [] if summary_file.exists(): for item in json.loads(summary_file.read_text()).get("results", []): if item.get("status") == "fail": failed.append(item["name"]) - log = slot / item.get("log", "") - tail = log.read_text(errors="replace").splitlines()[-15:] if log.is_file() else [] + log_path = slot / item.get("log", "") + tail = log_path.read_text(errors="replace").splitlines()[-15:] if log_path.is_file() else [] excerpts.append(f"### {item['name']}\n" + "\n".join(tail)) text = "\n\n".join(excerpts) or (result.stdout + result.stderr)[-2000:] normalized = re.sub(r"\d+(\.\d+)?", "#", re.sub(r"/\S+/", "/…/", text)) signature = ",".join(failed) + ":" + hashlib.sha256(normalized.encode()).hexdigest()[:12] - return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature + return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature, log def worktree_problem(slot: Path, base: str) -> str | None: @@ -291,7 +308,12 @@ def run_executor(self, task: Task, slot: Path, guard: Path, prompt: str, events: ) self.used_seconds += seconds model, usage, denials = self.host.parse(events) - return dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials) + counts = observation.tool_counts(self.host, events) + result = dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials, + counts["guard_allowed"]) + observation.executor_round(task.branch, slot, prompt, events, result, + {"name": self.host.name, "version": self.host.version()}, counts) + return result def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace_dir: Path) -> tuple[Attempt, list[str]]: """5 与 6:执行方 → verify,失败带摘要重试;返回本次尝试与每轮提示词。""" @@ -309,9 +331,11 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace attempt.usage[key] = round(attempt.usage.get(key, 0) + value, 6) 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 note = slot / ESCALATION_FILE if note.exists(): attempt.executor_note = note.read_text(encoding="utf-8")[:4000] + observation.clarify(task.branch, note) attempt.exit = "clarify" return attempt, prompts if result.exit != "ok": @@ -319,14 +343,16 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace return attempt, prompts problem = worktree_problem(slot, base) if problem: - ok, summary, signature = False, problem, problem + ok, summary, signature, log = False, problem, problem, "" else: - ok, summary, signature = verify_signature(slot, self.config) + ok, summary, signature, log = verify_signature(slot, self.config) + repeat = bool(attempt.signatures) and attempt.signatures[-1] == signature + observation.local_verify(task.branch, slot, base, ok, signature, log, repeat) if ok: attempt.ok, attempt.exit = True, "ok" return attempt, prompts attempt.verify_summary = summary - if attempt.signatures and attempt.signatures[-1] == signature: + if repeat: attempt.signatures.append(signature) attempt.exit = "loop" return attempt, prompts @@ -383,20 +409,25 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr def run(self, path: str, resume: bool = False) -> int: task = admit(path, self.root) if self.github.remote_branch_exists(task.branch) and not resume: + observation.claim(task.branch, False) raise Stop(f"远端已有 {task.branch}:该任务已被认领(续跑加 --resume)") - guard, guard_ref = prepare_guard(self.root) + guard, guard_ref = prepare_guard(self.root, task.branch) index, slot = acquire_slot(self.root, self.config, task) try: prepare_slot(self.root, slot, task.branch, resume) + observation.slot(task.branch, index, git("rev-parse", "HEAD", cwd=slot), + git("rev-parse", "origin/main", cwd=self.root)) if not resume: self.github.push(slot, task.branch) # 分支即认领 + observation.claim(task.branch, True) return self._loop(task, index, slot, guard, guard_ref) finally: release_slot(self.root, index) def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) -> int: runs = state_dir(self.root) / "runs" / task.id - records = slot / "docs" / "runs" / task.path.split("/")[-1].removesuffix(".md") + folder = task.path.split("/")[-1].removesuffix(".md") + records = slot / "docs" / "runs" / folder number = len(list(records.glob("*.json"))) + 1 if records.exists() else 1 pr, ci_rounds, feedback = None, 0, "" while True: @@ -409,11 +440,14 @@ def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) self.escalate(task, pr, attempt, f"本地未完成({EXIT_TEXT.get(attempt.exit, attempt.exit)})") return 1 if pr is None: - pr = self.github.open_pr(slot, task.branch, f"{task.id}:{_title(self.root, task)}", - pr_body(task, attempt, number, prompt_sha)) + body = pr_body(task, attempt, number, prompt_sha) + pr = self.github.open_pr(slot, task.branch, f"{task.id}:{_title(self.root, task)}", body) + observation.push_pr(task.branch, slot, pr, f"docs/runs/{folder}/{number}.json", body) ci_rounds += 1 - ok, summary = self.github.wait_ci(task.branch, git("rev-parse", "HEAD", cwd=slot), - self.config.ci_timeout_seconds) + head = git("rev-parse", "HEAD", cwd=slot) + detail: dict = {} + ok, summary = self.github.wait_ci(task.branch, head, self.config.ci_timeout_seconds, detail) + observation.ci_wait(task.branch, pr, head, ci_rounds, ok, summary, detail.get("run_ids")) if ok: print(f"{task.id}:PR #{pr} 的 CI 通过,合并由 auto-merge 判定") return 0 @@ -426,10 +460,16 @@ def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) def escalate(self, task: Task, pr: int | None, attempt: Attempt, reason: str) -> None: body = escalation_body(task, attempt, reason) - if pr is not None: - self.github.comment(pr, body, label="escalation") - else: - self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + target = "pr" if pr is not None else "issue" + sent = False + try: + if pr is not None: + self.github.comment(pr, body, label="escalation") + else: + self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + sent = True + finally: + observation.escalate(task.branch, pr, reason, target, body, sent) # 评论失败也留事件 print(f"{task.id}:{reason},已升级") @@ -517,10 +557,12 @@ def open_pr(self, slot: Path, branch: str, title: str, body: str) -> int: "--body-file", "-"], cwd=slot, agent=True, stdin=body) return int(url.rstrip("/").rsplit("/", 1)[-1]) - def comment(self, pr: int, body: str, label: str | None = None) -> None: - self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) + def comment(self, pr: int, body: str, label: str | None = None) -> str: + """评论 PR 并返回评论 URL(供观察事件记录;标签逻辑不变)。""" + url = self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) if label: self.add_label(pr, label) + return url def _ensure_label(self, label: str) -> None: """不存在才创建;不用 --force,已有标签(含登记用标签)不被改写。""" @@ -541,19 +583,47 @@ def create_issue(self, title: str, body: str, labels: list[str]) -> None: argv += ["--label", label] self._run(argv, agent=True, stdin=body) - def wait_ci(self, branch: str, sha: str, timeout: float) -> tuple[bool, str]: + def _ci_runs(self, workflow: str, branch: str) -> list[dict]: + """查一个工作流在该分支上的运行。gh 瞬时失败(如网络断开)连续 CI_QUERY_ATTEMPTS 次才抛出,避免一次瞬断中止整个派发。""" + for attempt in range(1, CI_QUERY_ATTEMPTS + 1): + try: + return json.loads(self._run(["gh", "run", "list", "--workflow", workflow, "--branch", branch, + "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) + except (RuntimeError, json.JSONDecodeError): + if attempt == CI_QUERY_ATTEMPTS: + raise + time.sleep(CI_QUERY_RETRY_SECONDS) + return [] + + def wait_ci(self, branch: str, sha: str, timeout: float, detail: dict | None = None) -> tuple[bool, str]: + """等 rules.toml [dispatch] ci_workflows 列出的每个工作流在该提交上跑完;全部成功才算通过。 + + detail 非 None 时写入 run_ids(本次结论对应的 Actions 运行 ID),供派发的 ci_wait 观察事件 + 使用;返回值与 gh 调用序列保持不变。 + """ + workflows = ci_workflows() deadline = time.monotonic() + timeout while time.monotonic() < deadline: - runs = json.loads(self._run(["gh", "run", "list", "--workflow", "build", "--branch", branch, - "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) - run = next((item for item in runs if item["headSha"] == sha), None) - if run and run["status"] == "completed": - if run["conclusion"] == "success": - return True, "" - log = subprocess.run(["gh", "run", "view", str(run["databaseId"]), "--log-failed"], cwd=self.root, + found = {} + for workflow in workflows: + runs = self._ci_runs(workflow, branch) + run = next((item for item in runs if item["headSha"] == sha), None) + if run and run["status"] == "completed": + found[workflow] = run + failed = next((run for run in found.values() if run["conclusion"] != "success"), None) + if failed: + log = subprocess.run(["gh", "run", "view", str(failed["databaseId"]), "--log-failed"], cwd=self.root, capture_output=True, text=True, check=False).stdout - return False, f"CI 未通过:{run['url']}\n\n```\n{log[-3000:]}\n```" + if detail is not None: + detail["run_ids"] = [failed["databaseId"]] + return False, f"CI 未通过:{failed['url']}\n\n```\n{log[-3000:]}\n```" + if len(found) == len(workflows): + if detail is not None: + detail["run_ids"] = sorted(run["databaseId"] for run in found.values()) + return True, "" time.sleep(30) + if detail is not None: + detail["run_ids"] = [] return False, f"等待 CI 超过 {int(timeout / 60)} 分钟" @@ -609,7 +679,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "status": return status() if args.command == "review": - import review as review_module # review 依赖本模块,按需导入 + from engine.agents import review as review_module # review 依赖本模块,按需导入 if args.watch: return review_module.watch(args.interval, args.reviewer) diff --git a/.harness/engine/agents/dispatch_host.py b/.harness/engine/agents/dispatch_host.py index 0a0dc6c..a525698 100644 --- a/.harness/engine/agents/dispatch_host.py +++ b/.harness/engine/agents/dispatch_host.py @@ -33,6 +33,7 @@ class RunResult: model: str = "" usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) def executor_env(base: dict[str, str], identity: dict[str, str], gh_config_dir: Path) -> dict[str, str]: @@ -156,3 +157,22 @@ def parse(self, events: Path) -> tuple[str, dict, dict[str, int]]: if "cost" in result: result["cost"] = round(result["cost"], 6) return model, result, dict(denials) + + 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": []} + if not events.exists(): + return counts + for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "tool_execution_end": + continue + content = (event.get("result") or {}).get("content") or [] + 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 diff --git a/.harness/engine/agents/review.py b/.harness/engine/agents/review.py index 09dcc93..7be9478 100644 --- a/.harness/engine/agents/review.py +++ b/.harness/engine/agents/review.py @@ -33,6 +33,7 @@ from typing import ClassVar from engine.agents import dispatch, dispatch_host +from engine.agents import dispatch_observation as observation from engine.core.common import ENGINE_DIR, ROOT, changed_files, commit_field, git, load_rules from engine.routing import run_check @@ -51,6 +52,9 @@ class Verdict: summary: str = "" parsed: bool = True failure: str = "" # 评审方本身失败(报错退出、超时、额度用尽):不是结论,不计入校准,不评论为结论 + error_kind: str = "" # 失败的机器类别(timeout / reviewer_exit / no_output),供评审观察事件 + audit_model: str | None = None # 审计摘要用的模型(明确报告优先,其次显式请求;两者皆无为 None) + model_basis: str = "unknown" # reported / explicit_request / unknown(共用合同 C6) @property def flagged(self) -> bool: @@ -79,13 +83,14 @@ def parse_output(text: str) -> Verdict: class Reviewer: name = "" + model_name: str | None = None # 显式请求的模型(审计摘要用;展示名可带档位等修饰) env: ClassVar[dict[str, str]] = {} # 评审方专用的环境变量(如 OpenCode 的权限配置) def argv(self, prompt: str, workspace: Path, output: Path) -> list[str]: raise NotImplementedError - def read(self, stdout: str, output: Path) -> tuple[str, str]: - """返回 (最终文本, 模型)。""" + def read(self, stdout: str, output: Path) -> tuple[str, str, str]: + """返回 (最终文本, 展示用模型, model_basis:reported / explicit_request / unknown)。""" raise NotImplementedError @@ -94,6 +99,7 @@ class CodexReviewer(Reviewer): def __init__(self, model: str | None, effort: str | None = None): self.model, self.effort = model, effort + self.model_name = model def argv(self, prompt, workspace, output): # 没配置模型时沿用 Codex 自己的默认设置。 @@ -105,7 +111,8 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): base = self.model or "codex 默认模型" label = f"{base} {self.effort}" if self.effort else base - return (output.read_text(encoding="utf-8") if output.exists() else stdout), label + basis = "explicit_request" if self.model else "unknown" + return (output.read_text(encoding="utf-8") if output.exists() else stdout), label, basis class ClaudeReviewer(Reviewer): @@ -113,6 +120,7 @@ class ClaudeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["claude", "-p", "--allowedTools", "Read,Grep,Glob", "--output-format", "json"] @@ -124,9 +132,11 @@ def read(self, stdout, output): try: data = json.loads(stdout) except json.JSONDecodeError: - return stdout, self.model or "" - model = next(iter(data.get("modelUsage") or {}), self.model or "") - return str(data.get("result", "")), model + return stdout, self.model or "", "explicit_request" if self.model else "unknown" + reported = next(iter(data.get("modelUsage") or {}), "") + model = reported or self.model or "" + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return str(data.get("result", "")), model, basis class PiReviewer(Reviewer): @@ -134,6 +144,7 @@ class PiReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["pi", "-na", "--tools", "read,grep,find,ls", "-p", "--mode", "json", "--no-session"] @@ -143,7 +154,7 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): """取最后一条助手消息的文本与模型(pi 的 json 事件流)。""" - text, model = "", self.model or "" + text, model, reported = "", self.model or "", "" for line in stdout.splitlines(): try: event = json.loads(line) @@ -154,8 +165,11 @@ def read(self, stdout, output): parts = [part.get("text", "") for part in message.get("content", []) if part.get("type") == "text"] if any(parts): text = "\n".join(parts) + if message.get("model"): + reported = message["model"] model = message.get("model", model) - return text, model + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return text, model, basis class OpenCodeReviewer(Reviewer): @@ -171,6 +185,7 @@ class OpenCodeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["opencode", "run", "--pure", "--agent", "plan", "--format", "json", "--dir", str(workspace)] @@ -189,7 +204,7 @@ def read(self, stdout, output): part = event.get("part") or {} if event.get("type") == "text" and part.get("type") == "text": texts.append(part.get("text", "")) - return "\n".join(texts), self.model or "" + return "\n".join(texts), self.model or "", "explicit_request" if self.model else "unknown" def make_reviewer(name: str) -> Reviewer: @@ -218,13 +233,20 @@ def run_reviewer(reviewer: Reviewer, workspace: Path, timeout: float) -> tuple[V result = subprocess.run(reviewer.argv(prompt, workspace, output), cwd=workspace, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout, check=False) - text, model = reviewer.read(result.stdout, output) + text, model, basis = reviewer.read(result.stdout, output) failure = "" if result.returncode == 0 else _failure_line(result.stdout + result.stderr, result.returncode) + error_kind = "" if result.returncode == 0 else "reviewer_exit" except subprocess.TimeoutExpired: - text, model, failure = "", "", f"超过 {int(timeout / 60)} 分钟未完成" + text, model, basis = "", "", "unknown" + failure, error_kind = f"超过 {int(timeout / 60)} 分钟未完成", "timeout" verdict = parse_output(text) if failure or not text.strip(): - verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出") + verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出", + error_kind=error_kind or "no_output") + # C6:模型明确报告优先;其次实际命令显式指定的请求模型;两者皆无不把配置默认值当已调用模型。 + verdict.audit_model = model if basis == "reported" else ( + reviewer.model_name if basis == "explicit_request" else None) + verdict.model_basis = basis return verdict, model, time.monotonic() - started @@ -310,7 +332,7 @@ def run_check_records(workspace: Path, base: str) -> list[dict]: def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | None, head: str, seconds: float, - same_host: bool = False) -> str: + same_host: bool = False, audit: dict | None = None) -> str: lines = [ f"### 独立评审(试行):{verdict.verdict}", "", f"- 评审方:{reviewer}({model or '模型未报告'});设计方:{designer or '未能判定'};评审的提交:`{head[:12]}`;用时 {seconds / 60:.1f} 分钟", @@ -328,6 +350,9 @@ def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | data = {"verdict": verdict.verdict, "reviewer": reviewer, "model": model, "head": head, "findings": len(verdict.findings), "flagged": verdict.flagged, "parsed": verdict.parsed} lines += ["", f""] + if audit is not None: + # C6 审计摘要:与旧标记并列,单行 JSON,字段与材料编码见共用合同 C6;T305 只解析这个新标记。 + lines.append(f"") return "\n".join(lines) + "\n" @@ -335,9 +360,11 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= github = github or dispatch.GitHub(root) pr = json.loads(github._run(["gh", "pr", "view", str(number), "--json", "title,body,headRefName,headRefOid,baseRefName,state,mergeCommit"])) + trace = pr["headRefName"] # 评审事件的显式 trace:PR 的 headRefName + head = pr["headRefOid"] workspace = review_workspace(root) git("fetch", "--quiet", "origin", f"pull/{number}/head", "main", cwd=workspace) - checkout(workspace, pr["headRefOid"]) + checkout(workspace, head) base = review_base(pr, workspace) designer = designer_of(base, "HEAD", pr["headRefName"], workspace) name = reviewer_name or load_rules().get("review", {}).get("reviewer") or OTHER.get(designer or "") @@ -350,14 +377,36 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= 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 "" 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) + 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( "timeout_minutes", 30) * 60) if verdict.failure: + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=name in executors, + failure=verdict.failure, error_kind=verdict.error_kind) print(f"PR #{number}:评审方失败({verdict.failure}),没有评论,保留待评审标签") return 1 - executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} - github.comment(number, render_comment(verdict, name, model, designer, pr["headRefOid"], seconds, - same_host=name in executors)) + same_host = name in executors + audit = observation.review_audit(trace_id=trace, head=head, base=base, reviewer=name, + model=verdict.audit_model, model_basis=verdict.model_basis, + designer=designer, + implementers=sorted(item for item in executors if item), + independent=independent, same_host=same_host, parsed=verdict.parsed, + verdict=verdict.verdict, duration_ms=int(seconds * 1000), + findings=verdict.findings, materials=materials) + body = render_comment(verdict, name, model, designer, head, seconds, same_host, audit) + url = github.comment(number, body) + # 事件在评论实际发布后写:URL 与评论字节哈希只有此刻可知(C6:摘要不预写 URL、不含自身哈希)。 + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=same_host, + comment=url, comment_body=body) try: github._run(["gh", "pr", "edit", str(number), "--remove-label", LABEL], agent=True) except RuntimeError: diff --git a/.harness/engine/checks/integrity.py b/.harness/engine/checks/integrity.py index 48a9dad..9a0bb0e 100644 --- a/.harness/engine/checks/integrity.py +++ b/.harness/engine/checks/integrity.py @@ -6,6 +6,8 @@ bin/harness integrity 核对(verify 各档运行) bin/harness integrity --print 打印当前目录树哈希 + +核对本身是一条观察事件(设计 3.3):lock 引用、目录摘要、文件计数与失败签名,写入失败不影响原判定。 """ from __future__ import annotations @@ -15,22 +17,34 @@ import json from pathlib import Path -from engine.core.common import ENGINE_DIR, HARNESS_DIR +from engine.core import events +from engine.core.common import ENGINE_DIR, HARNESS_DIR, git LOCK_FILE = HARNESS_DIR / "engine.lock" IGNORED_PARTS = {"__pycache__"} IGNORED_SUFFIXES = {".pyc", ".pyo"} -def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: - """按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256;忽略字节码缓存。""" +def _scan(engine_dir: Path) -> tuple[str, dict[str, str]]: + """一次遍历同时得到目录树哈希与逐文件哈希(同一过滤:忽略字节码缓存)。 + + 哈希算法与既有实现逐字节一致:按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256。 + """ digest = hashlib.sha256() + hashes: dict[str, str] = {} for path in sorted(p for p in engine_dir.rglob("*") if p.is_file()): rel = path.relative_to(engine_dir) if IGNORED_PARTS & set(rel.parts) or path.suffix in IGNORED_SUFFIXES: continue - digest.update(rel.as_posix().encode() + b"\0" + hashlib.sha256(path.read_bytes()).hexdigest().encode() + b"\n") - return f"sha256:{digest.hexdigest()}" + file_hash = hashlib.sha256(path.read_bytes()).hexdigest() + digest.update(rel.as_posix().encode() + b"\0" + file_hash.encode() + b"\n") + hashes[rel.as_posix()] = file_hash + return f"sha256:{digest.hexdigest()}", hashes + + +def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: + """目录树哈希(install 写锁与 integrity 核对共用同一算法)。""" + return _scan(engine_dir)[0] def read_lock(path: Path = LOCK_FILE) -> dict: @@ -42,7 +56,10 @@ def write_lock(path: Path, version: str, commit: str, tree: str) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") -def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[bool, str]: +def check(engine_dir: Path | None = None, lock_file: Path | None = None) -> tuple[bool, str]: + """核对引擎目录与锁文件;参数缺省取当前安装位置(延迟绑定,便于隔离测试与工具复用)。""" + engine_dir = ENGINE_DIR if engine_dir is None else engine_dir + lock_file = LOCK_FILE if lock_file is None else lock_file if engine_dir.parent != lock_file.parent: return True, "引擎不在业务仓库的 .harness/ 下(引擎仓库自身),无需核对" if not lock_file.exists(): @@ -58,6 +75,63 @@ def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[b return True, f"引擎 {lock.get('version')}({str(lock.get('commit', ''))[:12]})与锁文件一致 ✓" +def _changed_count(engine_dir: Path) -> int | None: + """引擎目录里未提交的变更文件数(含未跟踪)。 + + 锁文件只有整体摘要,无法逐文件回溯;未提交改动是「改了引擎而没走 upgrade」的主要形态, + 以 git HEAD 为基线给出被改文件数(不含内容)。git 不可用时返回 None。 + """ + try: + out = git("status", "--porcelain", "--untracked-files=all", "--", ".", + cwd=engine_dir, check=True, isolate=True) + except RuntimeError: + return None + return sum(1 for line in out.splitlines() if line.strip()) + + +def _metadata() -> tuple[list[dict], dict, str | None]: + """事件的 lock 引用、目录摘要、文件计数与失败签名;各项准备失败只让对应字段缺失。""" + inputs: list[dict] = [] + try: + inputs.append(events.file_ref("lock", LOCK_FILE)) + except OSError: + pass + signature = None if LOCK_FILE.exists() else "lock_missing" + outputs: dict = {} + try: + tree, hashes = _scan(ENGINE_DIR) + except OSError: + return inputs, outputs, signature + outputs["tree"] = tree + outputs["files"] = len(hashes) + changed = _changed_count(ENGINE_DIR) + if changed is not None: + outputs["changed"] = changed + try: + lock = read_lock(LOCK_FILE) + except Exception: # noqa: BLE001 缺锁或坏锁:事件只缺对应字段,不改变原判定 + lock = {} + if lock.get("tree"): + outputs["lock.tree"] = lock["tree"] + if signature is None and lock["tree"] != tree: + signature = "tree_mismatch" + return inputs, outputs, signature + + +def _record(ok: bool) -> None: + """观察旁路:一条 integrity 内容事件(stage=verify);准备失败不得影响原判定与输出。""" + try: + if ENGINE_DIR.parent != LOCK_FILE.parent: # 引擎仓库自身:核对不适用 + events.emit(stage="verify", step="integrity", status="skip") + return + inputs, outputs, signature = _metadata() + events.emit(stage="verify", step="integrity", status="ok" if ok else "fail", + inputs=inputs or None, outputs=outputs or None, + error=None if ok else {"kind": "integrity_mismatch", "signature": signature}) + except Exception: # noqa: BLE001 观察失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--print", action="store_true", help="打印当前目录树哈希") @@ -66,6 +140,7 @@ def main(argv: list[str] | None = None) -> int: print(tree_hash()) return 0 ok, message = check() + _record(ok) # 观察:lock 引用、目录摘要、变更文件计数;失败 error.kind=integrity_mismatch print(message) return 0 if ok else 1 diff --git a/.harness/engine/checks/verify.py b/.harness/engine/checks/verify.py index 9e7e0bf..1c11229 100644 --- a/.harness/engine/checks/verify.py +++ b/.harness/engine/checks/verify.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import platform @@ -22,6 +23,7 @@ import time from dataclasses import dataclass, field +from engine.core import events from engine.core.common import CLI, ENGINE_REL, ROOT, clean_git_env, git, setting LOG_DIR = ROOT / "build" / "verify" @@ -48,6 +50,7 @@ class Result: log: str = "" note: str = "" tail: list[str] = field(default_factory=list) + code: int | None = None # 检查命令的退出码;未运行(--skip、strict 转换)为 None def pinned_version(package: str) -> str | None: @@ -153,6 +156,49 @@ def build_checks(strict: bool = False) -> list[Check]: return [available[name] for name in names] +# 最终 Result(含 --skip 与 --strict 转换)到事件状态的映射(设计 3.3)。 +STATUS_BY_RESULT = {"pass": "ok", "fail": "fail", "skip": "skip"} + + +def _log_refs(log: str) -> dict: + """完整日志按原始字节算哈希并存为本机产物;读取或保存失败时省略对应字段,不影响判定。""" + if not log: + return {} + try: + data = (ROOT / log).read_bytes() + except OSError: + return {} + refs = {"log.sha256": hashlib.sha256(data).hexdigest(), "log.size": len(data)} + try: + stored = events.store_artifact(data) + except Exception: # noqa: BLE001 观察准备失败不影响原判定 + stored = None + if stored: + refs["log.ref"] = stored["ref"] + return refs + + +def emit_result(result: Result, info: dict, tier: str) -> None: + """每项检查的最终 Result 之后写 verify.(D029):head、档位、退出码、时长、失败签名与日志哈希。""" + try: + events.emit(stage="verify", step=f"verify.{result.name}", status=STATUS_BY_RESULT[result.status], + duration_ms=int(result.seconds * 1000), + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"exit": result.code, "signature": result.note or None, **_log_refs(result.log)}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + +def emit_summary(info: dict, tier: str, ok: bool) -> None: + """收尾写一条 verify.summary(D030):档位、通过与工作区状态。""" + try: + events.emit(stage="verify", step="verify.summary", status="ok" if ok else "fail", + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"tier": tier, "ok": ok, "dirty": info["dirty"]}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + def run_check(check: Check) -> Result: if check.requires == "macos" and not _is_macos(): return Result(check.name, "skip", note=check.why_skipped) @@ -179,9 +225,9 @@ def run_check(check: Check) -> Result: seconds = time.monotonic() - started relative = str(target.relative_to(ROOT)) if code == 0: - return Result(check.name, "pass", seconds, relative) + return Result(check.name, "pass", seconds, relative, code=code) tail = target.read_text(errors="replace").splitlines()[-TAIL_LINES:] - return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail) + return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail, code=code) def head_info() -> dict: @@ -237,6 +283,7 @@ def main(argv: list[str] | None = None) -> int: if result.status == "skip" and args.strict: result = Result(check.name, "fail", note=f"--strict 下不允许跳过({result.note})") results.append(result) + emit_result(result, info, tier_name) # 最终 Result(含 strict 转换)之后埋点,--skip 与 strict 都不漏 mark = {"pass": "✓", "fail": "✗", "skip": "-"}[result.status] extra = f" {result.note}" if result.note else "" print(f"{mark} {result.name:<15} {result.seconds:5.1f}s{extra}", flush=True) @@ -259,6 +306,7 @@ def main(argv: list[str] | None = None) -> int: "ok": not failed, } (LOG_DIR / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + emit_summary(info, tier_name, not failed) write_step_summary(results, info) if args.json: print(json.dumps(summary, ensure_ascii=False, indent=2)) diff --git a/.harness/engine/cli.py b/.harness/engine/cli.py index ea8dbd9..df156ff 100644 --- a/.harness/engine/cli.py +++ b/.harness/engine/cli.py @@ -7,6 +7,7 @@ import importlib import sys +import time from pathlib import Path # 让 `engine` 包可导入:引擎目录的上一级(业务仓库的 .harness/,或引擎仓库根)。 @@ -47,11 +48,45 @@ "upgrade": "engine.core.install:upgrade_main", } +# 入口事件的阶段映射(共用合同 C1):判定入口归 verify,判级与路由归 route,派发归 dispatch, +# 评审归 review;表外的其余检查、报告与安装升级归 ci。新增入口必须落进这张表或登记例外。 +COMMAND_STAGE = { + "verify": "verify", + "integrity": "verify", + "risk": "route", + "policy": "route", + "dispatch": "dispatch", + "review": "review", + "review-pack": "review", +} +# 不记通用入口事件的命令:guard 只记拒绝(T104,放行不逐条记,设计 3.6);events/trace 查询、 +# audit/alert 发布各自记事件,避免递归(C1)。 +QUIET_COMMANDS = frozenset({"guard-command", "guard-git", "events", "trace", "audit", "alert"}) + def usage() -> str: return "用法:cli.py <子命令> [参数…]\n子命令:" + "、".join(COMMANDS) +def _record_dispatch(name: str, started: float, outcome) -> None: + """分派边界的观察旁路:一条 cli.<命令> 事件(时长、返回码或异常类别);永不影响原行为。""" + if name in QUIET_COMMANDS: + return + from engine.core import events # 延迟导入:钩子等高频路径不付不必要的导入成本 + + if isinstance(outcome, SystemExit): + code = outcome.code if isinstance(outcome.code, int) else (0 if outcome.code is None else 1) + status = "ok" if code == 0 else "fail" + error = {"kind": "SystemExit"} if code else None + elif isinstance(outcome, BaseException): + code, status, error = None, "error", {"kind": type(outcome).__name__} + else: + code, status, error = outcome, ("ok" if outcome == 0 else "fail"), None + events.emit(stage=COMMAND_STAGE.get(name, "ci"), step=f"cli.{name}", status=status, + duration_ms=int((time.monotonic() - started) * 1000), + outputs={"code": code}, error=error) + + def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv if not args or args[0] in {"-h", "--help"}: @@ -62,9 +97,19 @@ def main(argv: list[str] | None = None) -> int: print(f"未知子命令:{name}\n{usage()}", file=sys.stderr) return 2 module_name, _, function = COMMANDS[name].partition(":") - module = importlib.import_module(module_name) - sys.argv = [f"cli.py {name}", *rest] - return getattr(module, function or "main")(rest) + started = time.monotonic() + try: + module = importlib.import_module(module_name) + sys.argv = [f"cli.py {name}", *rest] + code = getattr(module, function or "main")(rest) + except SystemExit as exc: + _record_dispatch(name, started, exc) # 仅记录,原样重抛 + raise + except BaseException as exc: + _record_dispatch(name, started, exc) # 业务异常同样记录后原样重抛 + raise + _record_dispatch(name, started, code) + return code if __name__ == "__main__": diff --git a/.harness/engine/core/common.py b/.harness/engine/core/common.py index f588516..295ad98 100644 --- a/.harness/engine/core/common.py +++ b/.harness/engine/core/common.py @@ -123,6 +123,14 @@ def setting(section: str, key: str, default=None, *, required: bool = False, sou return default +def ci_workflows() -> list[str]: + """判定 CI 通过与统计 CI 轮次所依据的工作流名(rules.toml [dispatch] ci_workflows,缺省 ["harness"],即模板的工作流)。""" + names = setting("dispatch", "ci_workflows", ["harness"], source="rules") + if not isinstance(names, list) or not names or not all(isinstance(name, str) and name for name in names): + raise ConfigError(".harness/config/rules.toml 的 [dispatch] ci_workflows 须是非空的工作流名列表") + return names + + # 目录约定(与项目无关的通用布局,写在引擎 README): # docs/specs/ 现役规格与验收表 docs/plans/task-*.md 任务书 # docs/runs/ 运行记录 docs/templates/ 模板 diff --git a/.harness/engine/guards/command_guard.py b/.harness/engine/guards/command_guard.py index e263bb9..3a18afd 100644 --- a/.harness/engine/guards/command_guard.py +++ b/.harness/engine/guards/command_guard.py @@ -8,6 +8,8 @@ --format json {"command": "..."}、{"file_path": "..."} 或 {"tool_name": "..."}(OpenCode 插件用) --format plain 整段 stdin 就是命令 拒绝时退出码 2,理由写 stderr(Claude Code 会把它反馈给模型);放行退出码 0。 +拒绝在本报告边界逐条写 guard 事件(规则键、角色、工具类别、安全相对目标;理由与命令全文不进事件), +放行不记也不计数(设计 3.6,执行环节的放行数由 T105 从工具完成事件汇总)。 --role implementer 另外禁止编辑判定器与护栏(CI、hooks、harness、Agent 配置):执行者改不了判定器; 也不能编辑合同(任务书与现役规格)与运行记录(docs/runs/),不能关闭、改动议题与标签。 @@ -22,61 +24,74 @@ import sys from pathlib import Path -from engine.core import shell_structure +from engine.core import events, shell_structure from engine.core.common import path_matches, setting -# (模式, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 -COMMAND_RULES: list[tuple[str, str]] = [ +# (模式, 规则键, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 +# 规则键是拒绝事件里的稳定标识(设计 3.6):理由文案可能调整且可能含命令字样,事件只记键、不复制理由。 +COMMAND_RULES: list[tuple[str, str, str]] = [ ( r"\bgit\s+filter-(repo|branch)\b|(^|[;&|(]|&&)\s*(\S*/)?git-filter-repo\b", + "history_rewrite", "改写历史(v0.8.0 曾因此改写 main 与 18 个 tag)", ), # 结尾用「后面不是词字符」而非空白:解释器代码里常紧跟引号或括号(os.system('git push --force'))。 - (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "强制推送:已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s\+\S+", "强制推送(+refspec):已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "批量推送(--mirror/--all/--tags)"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "直接推送 main:请推功能分支并开 PR"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "推送 tag:推 tag 会触发发布,只能由用户执行"), - (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "创建、移动或删除 tag:发版由用户执行"), - (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "直接改写 main 或 tag 引用"), - (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "--no-verify 跳过 git 守卫"), - (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "-n(--no-verify)跳过 git 守卫"), + (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "force_push", + "强制推送:已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s\+\S+", "force_push_refspec", "强制推送(+refspec):已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "batch_push", "批量推送(--mirror/--all/--tags)"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "push_main", "直接推送 main:请推功能分支并开 PR"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "push_tag", + "推送 tag:推 tag 会触发发布,只能由用户执行"), + (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "tag_write", + "创建、移动或删除 tag:发版由用户执行"), + (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "update_ref_protected", "直接改写 main 或 tag 引用"), + (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "no_verify", "--no-verify 跳过 git 守卫"), + (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "no_verify_short", "-n(--no-verify)跳过 git 守卫"), ( # 只拦设置或取消;`git config --get` 等只读查询放行(此前任何提及都拦,误伤自检)。 ( r"(?i)-c\s*core\.hookspath=|\bgit\s+config\b(?![^;&|]*--(get|get-all|get-regexp|list|show-origin)\b)" r"[^;&|]*core\.hookspath" ), + "hookspath_write", "修改 core.hooksPath 会绕过 git 守卫", ), # 覆盖变量:出现变量名或其后半截即拒(拼接如 ${P}_ALLOW_TAG 也能命中;评审 PR7-R6)。 - (r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b", "覆盖变量只供人使用,Agent 不能自行放开守卫"), - (r"\bgit\s+reset\s+[^;&|]*--hard\b", "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), + (r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b", "override_var", + "覆盖变量只供人使用,Agent 不能自行放开守卫"), + (r"\bgit\s+reset\s+[^;&|]*--hard\b", "reset_hard", + "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), # 解析失败时的兜底(宁可误报):-x 且没有任何 -e 排除即拒;能解析时由 shell_structure 按 [runtime] preserve 精确判断。 - (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), + (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "clean_keep", + "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), ( r"\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\b[^;&|]*?\s(/(?!tmp/|private/tmp/|var/folders/)|~|\$HOME|\.\.)", + "rm_outside", "递归删除工作区以外的路径(临时目录 /tmp 除外)", ), # 只读的 list、view 放行(审计 G11);创建、编辑、上传、删除发布由用户执行。 - (r"\bgh\s+release\b(?!\s+(list|view)\b)", shell_structure.RELEASE), + (r"\bgh\s+release\b(?!\s+(list|view)\b)", "release_write", shell_structure.RELEASE), # 议题与标签是逃逸、抽审、升级的登记(设计 14.3):登记只会让系统更保守,删除或撤标签会让误差预算失真。 - (r"\bgh\s+issue\s+delete\b", shell_structure.ISSUE_DELETE), + (r"\bgh\s+issue\s+delete\b", "issue_delete", shell_structure.ISSUE_DELETE), ( r"\bgh\s+(issue|pr)\s+edit\b[^;&|]*--remove-label[\s=][^;&|]*\b(escape|audit|escalation|budget-exceeded)\b", + "label_erase", shell_structure.LABEL_ERASE, ), ( r"\bgh\s+label\s+(delete|edit)\b[^;&|]*\b(escape|audit|escalation|budget-exceeded|class:K\d)\b", + "label_erase", shell_structure.LABEL_ERASE, ), - (r"\bgh\s+(run|repo)\s+delete\b", "删除 CI 记录或远端资源会销毁证据"), + (r"\bgh\s+(run|repo)\s+delete\b", "delete_remote", "删除 CI 记录或远端资源会销毁证据"), ( # gh api / curl 的写请求可绕过分支保护:PATCH 改远端 ref 即服务端强推,PUT contents 直写 main(评审 PR7-R6)。 ( r"(?i)\bgh\s+api\b[^;&|]*\s(-X\s*|--method[\s=]+)(POST|PUT|PATCH|DELETE)\b" r"|\bgh\s+api\b[^;&|]*\s(-f|-F|--field|--raw-field|--input)(\s|=)" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ( @@ -87,32 +102,35 @@ r"(?=[^;&|]*\s(-X\s*|--request[\s=]+)(?i:POST|PUT|PATCH|DELETE)\b" r"|[^;&|]*\s(-[dFT]\S*|--data\S*|--form\S*|--upload-file|--json)(\s|=|$))" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ] # 合并 PR 不归 Agent(用户 2026-09-25 决定 D4):R0/R1 由仓库 auto-merge 在门禁全绿后合并,R2 以上由用户合并。 MERGE_REASON = "合并 PR:R0/R1 由仓库 auto-merge 合并,R2 及以上由用户合并,Agent 不自行合并(方案 §13 D4)" -COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", MERGE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", "merge_pr", MERGE_REASON)) # 批准也不归 Agent(D3):ruleset 要求非推送者批准最后一次推送,Agent 批准就等于替用户放行。 APPROVE_REASON = "批准 PR:合并前须由非推送者批准;R0/R1 由 auto-merge 的 App 批准,R2 及以上由用户批准,Agent 不批准(方案 §13 D3)" -COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", APPROVE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", "approve_pr", APPROVE_REASON)) # 执行者另外不能关闭、重开、改动议题(含改标签):逃逸与抽审由设计评审方处理(设计 14.3)。 ISSUE_REASON = shell_structure.ISSUE_IMPLEMENTER -IMPLEMENTER_COMMAND_RULES: list[tuple[str, str]] = [ - (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", ISSUE_REASON), - (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", ISSUE_REASON), - (r"\bgh\s+label\b(?!\s+list\b)", ISSUE_REASON), +IMPLEMENTER_COMMAND_RULES: list[tuple[str, str, str]] = [ + (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+label\b(?!\s+list\b)", "issue_write", ISSUE_REASON), ] # 按工具名拒绝的 MCP 等非命令工具(如 GitHub MCP 的 merge_pull_request、enable_pr_auto_merge)。 -TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", shell_structure.LABEL_ERASE), - (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", MERGE_REASON), +TOOL_RULES: list[tuple[str, str, str]] = [ + (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", "tool_label_erase", + shell_structure.LABEL_ERASE), + (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", "tool_merge_pr", MERGE_REASON), # MCP 的评审工具按名字看不出是批准还是评论,一律拒绝;Agent 的评审意见写进报告或用 gh pr comment。 ( ( r"(?i)(^|[_.])(pull_request_review_write|create_pull_request_review|create_and_submit_pull_request_review" r"|submit_pending_pull_request_review|approve_pull_request|approve_pr)$" ), + "tool_approve_pr", APPROVE_REASON, ), ] @@ -141,19 +159,68 @@ def protected_for_implementer() -> list[str]: CONTRACTS_FOR_IMPLEMENTER = ["docs/plans/task-*.md", "docs/specs/**"] # 运行记录由派发脚本生成,是度量与审计的输入(设计 10.1),执行方不能编辑。 RUNS_FOR_IMPLEMENTER = ["docs/runs/**"] -IMPLEMENTER_TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", ISSUE_REASON), +IMPLEMENTER_TOOL_RULES: list[tuple[str, str, str]] = [ + ( + r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", + "tool_issue_write", + ISSUE_REASON, + ), ] -_COMPILED = [(re.compile(pattern), reason) for pattern, reason in COMMAND_RULES] -_TOOLS = [(re.compile(pattern), reason) for pattern, reason in TOOL_RULES] -_IMPLEMENTER_COMMANDS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_COMMAND_RULES] -_IMPLEMENTER_TOOLS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_TOOL_RULES] +_COMPILED = [(re.compile(pattern), key, reason) for pattern, key, reason in COMMAND_RULES] +_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in TOOL_RULES] +_IMPLEMENTER_COMMANDS = [(re.compile(pattern), key, reason) + for pattern, key, reason in IMPLEMENTER_COMMAND_RULES] +_IMPLEMENTER_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in IMPLEMENTER_TOOL_RULES] + +# 结构化规则(shell_structure)给出的理由 → 规则键;随后命令字符串规则表覆盖同名理由(同类拒绝共用一键)。 +_COMMAND_REASON_KEYS: dict[str, str] = { + shell_structure.FILTER: "history_rewrite", + shell_structure.FORCE: "force_push", + shell_structure.FORCE_REFSPEC: "force_push_refspec", + shell_structure.BATCH: "batch_push", + shell_structure.PUSH_MAIN: "push_main", + shell_structure.PUSH_TAG: "push_tag", + shell_structure.TAG: "tag_write", + shell_structure.UPDATE_REF: "update_ref_protected", + shell_structure.NO_VERIFY: "no_verify", + shell_structure.NO_VERIFY_SHORT: "no_verify_short", + shell_structure.HOOKS_PATH: "hookspath_write", + shell_structure.OVERRIDE: "override_var", + shell_structure.RESET_HARD: "reset_hard", + shell_structure.RM_OUTSIDE: "rm_outside", + shell_structure.RELEASE: "release_write", + shell_structure.ISSUE_DELETE: "issue_delete", + shell_structure.LABEL_ERASE: "label_erase", + shell_structure.ISSUE_IMPLEMENTER: "issue_write", + shell_structure.DELETE_REMOTE: "delete_remote", + shell_structure.API_WRITE: "api_write", + shell_structure.MERGE: "merge_pr", + shell_structure.APPROVE: "approve_pr", +} +for _rules in (COMMAND_RULES, IMPLEMENTER_COMMAND_RULES): + for _pattern, _key, _reason in _rules: + _COMMAND_REASON_KEYS[_reason] = _key +_TOOL_REASON_KEYS = {reason: key for _pattern, key, reason in TOOL_RULES + IMPLEMENTER_TOOL_RULES} +# 带填充值的理由(clean_keep 的保留路径清单)按前缀取键。 +_REASON_KEY_PREFIXES = [(shell_structure.CLEAN_X.split("{paths}")[0], "clean_keep")] + + +def rule_key(reason: str, category: str = "command") -> str: + """拒绝理由 → 稳定规则键。理由文本可能含命令、路径或会话正文,永远不进事件; + 未登记的理由(如将来新增规则暂未配键)统一记 guard_denial,只影响粒度、不影响拒绝本身。""" + table = _TOOL_REASON_KEYS if category == "tool" else _COMMAND_REASON_KEYS + if reason in table: + return table[reason] + for prefix, key in _REASON_KEY_PREFIXES: + if reason.startswith(prefix): + return key + return "guard_denial" def check_tool(name: str, role: str = "designer") -> list[str]: rules = _TOOLS + (_IMPLEMENTER_TOOLS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(name)] + return [reason for pattern, _key, reason in rules if pattern.search(name)] def check_command(command: str, role: str = "designer") -> list[str]: @@ -172,7 +239,8 @@ def check_command_text(command: str, role: str = "designer") -> list[str]: # 再检查一遍去掉引号与反斜杠的形式:`"HARNESS"'_ALLOW_TAG'`、`HAR\\NESS_...` 这类拆写(PR7-R6)。 unquoted = re.sub(r"[\"'\\]", "", normalized) rules = _COMPILED + (_IMPLEMENTER_COMMANDS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(normalized) or pattern.search(unquoted)] + return [reason for pattern, _key, reason in rules + if pattern.search(normalized) or pattern.search(unquoted)] def _relative(path: str, root: Path) -> str: @@ -185,22 +253,66 @@ def _relative(path: str, root: Path) -> str: return candidate.as_posix().removeprefix("./") -def check_edit(path: str, role: str, root: Path) -> list[str]: +def _edit_denials(path: str, role: str, root: Path) -> list[tuple[str, str]]: + """编辑拒绝的 (规则键, 理由)。理由含目标路径,事件里只取键与安全目标(见 _safe_target)。""" if role != "implementer": return [] relative = _relative(path, root) pattern = path_matches(relative, protected_for_implementer()) if pattern and relative not in IMPLEMENTER_EDITABLE: - return [f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方"] + return [("edit_protected", f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方")] pattern = path_matches(relative, CONTRACTS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包"] + return [("edit_contract", f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包")] pattern = path_matches(relative, RUNS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成"] + return [("edit_runs", f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成")] return [] +def check_edit(path: str, role: str, root: Path) -> list[str]: + return [reason for _key, reason in _edit_denials(path, role, root)] + + +def _safe_target(path: str, root: Path) -> str | None: + """编辑拒绝的仓库相对目标:只在能安全归化为仓库内相对路径时返回,否则省略(None)。 + + 不把本机绝对路径清洗成貌似可信的相对路径:根外绝对路径、逃出仓库的相对路径一律省略。 + """ + candidate = Path(path) + try: + if candidate.is_absolute(): + return candidate.resolve().relative_to(root.resolve()).as_posix() + relative = candidate.as_posix().removeprefix("./") + except (ValueError, OSError): + return None + parts = Path(relative).parts + if not relative or parts in ((".",), ("/",)) or ".." in parts: + return None + return relative + + +def denial_entries(payload: dict, role: str = "designer", + root: Path | None = None) -> list[tuple[str, str, str, str | None]]: + """报告边界的拒绝条目 (理由, 类别, 规则键, 安全相对目标):evaluate 的理由序列即本表的理由投影。 + + 类别为 tool / command / edit;目标只对编辑拒绝从载荷的文件路径安全提取,命令拒绝不解析命令文本、 + 一律省略。理由可能含命令、路径或会话正文,调用方不得把它写进事件。 + """ + root = root or Path.cwd() + command, path = extract(payload) + entries: list[tuple[str, str, str, str | None]] = [] + tool = payload.get("tool_name") + if isinstance(tool, str): + entries += [(reason, "tool", rule_key(reason, "tool"), None) for reason in check_tool(tool, role)] + if command: + entries += [(reason, "command", rule_key(reason), None) for reason in check_command(command, role)] + if path: + target = _safe_target(path, root) + entries += [(reason, "edit", key, target) for key, reason in _edit_denials(path, role, root)] + return entries + + def extract(payload: dict) -> tuple[str | None, str | None]: """从各家载荷中取出 (命令, 文件路径)。""" tool_input = payload.get("tool_input", payload) @@ -214,17 +326,26 @@ def extract(payload: dict) -> tuple[str | None, str | None]: def evaluate(payload: dict, role: str = "designer", root: Path | None = None) -> list[str]: - root = root or Path.cwd() - command, path = extract(payload) - reasons = [] - tool = payload.get("tool_name") - if isinstance(tool, str): - reasons += check_tool(tool, role) - if command: - reasons += check_command(command, role) - if path: - reasons += check_edit(path, role, root) - return reasons + """拒绝理由列表:按工具、命令、编辑顺序聚合,按理由去重(口径与埋点前的判定一致)。""" + return list(dict.fromkeys(entry[0] for entry in denial_entries(payload, role, root))) + + +def _emit_denials(entries: list[tuple[str, str, str, str | None]], role: str) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有规则键、角色、类别与安全相对目标。 + + 理由与命令全文不进事件;emit 失败自行吞掉并提示一次,退出码与 stderr 的拒绝文本不受影响。 + """ + seen: set[tuple[str, str, str | None]] = set() + for _reason, category, key, target in entries: + identity = (category, key, target) + if identity in seen: + continue + seen.add(identity) + outputs = {"category": category} + if target: + outputs["target"] = target + events.emit("guard", "command", "deny", decision={"by": "guard", "rule": key}, + outputs=outputs, actor={"role": role}) def main(argv: list[str] | None = None) -> int: @@ -241,13 +362,14 @@ def main(argv: list[str] | None = None) -> int: except json.JSONDecodeError: print("harness 守卫:无法解析工具调用载荷,按拒绝处理。", file=sys.stderr) return 2 - reasons = evaluate(payload, args.role) - if not reasons: + entries = denial_entries(payload, args.role) + if not entries: return 0 print("harness 守卫拒绝了这次操作:", file=sys.stderr) - for reason in dict.fromkeys(reasons): + for reason in dict.fromkeys(entry[0] for entry in entries): print(f" - {reason}", file=sys.stderr) print(" 如确需执行,请把原因和命令交给用户决定(规则见 .harness/config/rules.toml)。", file=sys.stderr) + _emit_denials(entries, args.role) return 2 diff --git a/.harness/engine/guards/git_guard.py b/.harness/engine/guards/git_guard.py index 63619a8..5aeec42 100644 --- a/.harness/engine/guards/git_guard.py +++ b/.harness/engine/guards/git_guard.py @@ -9,6 +9,8 @@ 对应 v0.8.0 X3:filter-repo 改写了本地 main 与 18 个 tag,并删除了 origin。 覆盖变量见 .harness/config/rules.toml [guard];只供人使用。 +三个钩子的拒绝在报告边界逐条写 guard 事件(钩子名、分支、规则键;理由文本含 ref 与规则说明,不进事件), +放行不记(设计 3.6)。 """ from __future__ import annotations @@ -20,6 +22,7 @@ from pathlib import Path from engine.checks import hygiene +from engine.core import events from engine.core.common import LEGACY_RULES_REL, ROOT, RULES_REL, ZERO_SHA, git, load_rules HOOKS_DIR = ".githooks" @@ -99,11 +102,11 @@ def _packing_loose_ref(ref: str, old: str, cwd: Path) -> bool: return _transaction_subcommand() == "pack-refs" -def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - """reference-transaction 的 prepared 阶段:返回应拒绝的更新。""" +def ref_transaction_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """reference-transaction 的 prepared 阶段:返回应拒绝的 (规则键, 理由, 引用)。""" if _allowed("HARNESS_ALLOW_REWRITE"): return [] - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 3: @@ -116,17 +119,18 @@ def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list continue # 散文件并入 packed-refs,引用本身不变 if ref.startswith("refs/tags/") and old != ZERO_SHA and old != new: action = "删除" if new == ZERO_SHA else "移动" - problems.append(f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)") + denials.append(("tag_rewrite", f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)", ref)) elif _protected(ref, rules) and old != ZERO_SHA and old != new: if new == ZERO_SHA: - problems.append(f"删除保护分支 {ref}") + denials.append(("protected_rewrite", f"删除保护分支 {ref}", ref)) elif not _is_ancestor(old, new, cwd): - problems.append(f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)") - return problems + denials.append(("protected_rewrite", f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)", ref)) + return denials -def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - problems = [] +def pre_push_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """pre-push 的引用检查:返回应拒绝的 (规则键, 理由, 远端引用)。""" + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4: @@ -134,18 +138,22 @@ def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: _local_ref, local_sha, remote_ref, remote_sha = parts if remote_ref.startswith("refs/tags/"): if not _allowed("HARNESS_ALLOW_TAG"): - problems.append(f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行") + denials.append(("push_tag", f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行", remote_ref)) continue if _protected(remote_ref, rules) and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR") + denials.append(("push_protected_branch", f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR", remote_ref)) continue if local_sha == ZERO_SHA or remote_sha == ZERO_SHA or _allowed("HARNESS_ALLOW_REWRITE"): continue # 删除分支、新建分支 if not _exists(remote_sha, cwd): - problems.append(f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交") + denials.append(("push_stale_remote", + f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交", + remote_ref)) elif not _is_ancestor(remote_sha, local_sha, cwd): - problems.append(f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写") - return problems + denials.append(("force_push", + f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写", + remote_ref)) + return denials def current_branch(cwd: Path) -> str: @@ -175,38 +183,46 @@ def _run_verify(repo: Path, *args: str) -> int: def cmd_pre_commit(repo: Path) -> int: rules = trusted_rules(repo) - problems = [] + denials: list[tuple[str, str, str]] = [] branch = current_branch(repo) if branch in rules["guard"]["protected_branches"] and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"在保护分支 {branch} 上提交:请在功能分支上工作") - problems += [violation.render() for violation in hygiene.scan_staged(repo, rules)] - if _report("提交", problems): + denials.append(("commit_protected_branch", f"在保护分支 {branch} 上提交:请在功能分支上工作", branch)) + denials += [("staged_hygiene", violation.render(), branch) for violation in hygiene.scan_staged(repo, rules)] + code = _report("提交", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-commit", denials) return 1 return _run_verify(repo, "--quick") -def push_hygiene_violations(lines: list[str], repo: Path, rules: dict) -> list[str]: +def push_hygiene_denials(lines: list[str], repo: Path, rules: dict) -> list[tuple[str, str, str]]: """本次推送带出的改动做卫生检查(与 CI 的 --range 同口径,提前在本机发现)。""" - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4 or parts[1] == ZERO_SHA or parts[2].startswith("refs/tags/"): continue - local_sha, remote_sha = parts[1], parts[3] + local_sha, remote_ref, remote_sha = parts[1], parts[2], parts[3] if remote_sha != ZERO_SHA and _exists(remote_sha, repo): base = remote_sha else: base = git("merge-base", local_sha, "origin/main", cwd=repo, check=False) if not base: continue - problems += [violation.render() for violation in hygiene.scan_range(base, local_sha, repo, rules)] - return problems + denials += [("push_hygiene", violation.render(), remote_ref) + for violation in hygiene.scan_range(base, local_sha, repo, rules)] + return denials def cmd_pre_push(repo: Path, stdin: str) -> int: rules = trusted_rules(repo) lines = stdin.splitlines() - if _report("推送", pre_push_violations(lines, repo, rules) or push_hygiene_violations(lines, repo, rules)): + denials = pre_push_denials(lines, repo, rules) + if not denials: + denials = push_hygiene_denials(lines, repo, rules) + code = _report("推送", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-push", denials) return 1 return _run_verify(repo) @@ -214,7 +230,19 @@ def cmd_pre_push(repo: Path, stdin: str) -> int: def cmd_reference_transaction(repo: Path, state: str, stdin: str) -> int: if state != "prepared": return 0 - return _report("引用更新", ref_transaction_violations(stdin.splitlines(), repo, trusted_rules(repo))) + denials = ref_transaction_denials(stdin.splitlines(), repo, trusted_rules(repo)) + code = _report("引用更新", [denial[1] for denial in denials]) + if code: + _emit_denials("reference-transaction", denials) + return code + + +def _emit_denials(hook: str, denials: list[tuple[str, str, str]]) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有钩子名、分支与规则键,理由文本不进事件; + 事件写入失败由 emit 吞掉并提示一次,钩子的拒绝输出与退出码不变。""" + for key, _message, branch in denials: + events.emit("guard", "git", "deny", decision={"by": "guard", "rule": key}, + outputs={"hook": hook, "branch": branch}) def hooks_path(cwd: Path = ROOT) -> str: diff --git a/.harness/engine/reports/metrics.py b/.harness/engine/reports/metrics.py index cf0e9a3..2f3b6b4 100644 --- a/.harness/engine/reports/metrics.py +++ b/.harness/engine/reports/metrics.py @@ -2,7 +2,7 @@ bin/harness metrics --base origin/main [--head HEAD] [--github] [--json] ---github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 build workflow 运行(CI 轮次、失败轮次); +--github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 CI 工作流运行(rules.toml [dispatch] ci_workflows)(CI 轮次、失败轮次); 没有凭据时标为「不可用」,不猜。评审轮次、自述不实、人工介入由人记录在交付说明中(见 docs/plans/verifiable-delivery.md 的试跑规程)。 """ @@ -18,7 +18,7 @@ from engine.checks import evidence from engine.core.cases import load_project_cases -from engine.core.common import ROOT, added_lines, git, setting +from engine.core.common import ROOT, added_lines, ci_workflows, git, setting from engine.routing import risk, run_check @@ -50,14 +50,15 @@ def github_runs(branch: str) -> dict | None: token, repo = os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_REPOSITORY") if not token or not repo: return None - query = urllib.parse.urlencode({"branch": branch, "per_page": 100}) + query = urllib.parse.urlencode({"branch": branch, "event": "pull_request", "per_page": 100}) request = urllib.request.Request( - f"https://api.github.com/repos/{repo}/actions/workflows/build.yml/runs?{query}", + f"https://api.github.com/repos/{repo}/actions/runs?{query}", headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, ) try: with urllib.request.urlopen(request, timeout=20) as response: - runs = json.load(response).get("workflow_runs", []) + names = set(ci_workflows()) + runs = [run for run in json.load(response).get("workflow_runs", []) if run.get("name") in names] except (OSError, ValueError): return None return { diff --git a/.harness/engine/reports/weekly.py b/.harness/engine/reports/weekly.py index 6614150..bae1e43 100644 --- a/.harness/engine/reports/weekly.py +++ b/.harness/engine/reports/weekly.py @@ -22,7 +22,7 @@ from pathlib import Path from engine.checks import r1_checks -from engine.core.common import ROOT, git, setting +from engine.core.common import ROOT, ci_workflows, git, setting from engine.reports import metrics from engine.routing import policy, risk @@ -120,8 +120,10 @@ def issues(label: str) -> list[dict]: for pr in week.prs: week.modified_tests += _modifies_tests(pr, cwd) if pr["headRefName"] in dispatched: - builds = json.loads(gh("run", "list", "--workflow", "build", "--branch", pr["headRefName"], "--limit", "100", - "--json", "headSha,status,event")) + builds = [] + for workflow in ci_workflows(): + builds += json.loads(gh("run", "list", "--workflow", workflow, "--branch", pr["headRefName"], + "--limit", "100", "--json", "headSha,status,event")) week.ci_rounds[pr["number"]] = len({run["headSha"] for run in builds if run["status"] == "completed" and run["event"] == "pull_request"}) return week diff --git a/.harness/engine/routing/policy.py b/.harness/engine/routing/policy.py index cc64e3c..5b44ac9 100644 --- a/.harness/engine/routing/policy.py +++ b/.harness/engine/routing/policy.py @@ -9,8 +9,8 @@ 3. 预算 该类最近 window 次合并中的 escape 议题不超过 max_escapes;PR 不带 budget-exceeded 标签 4. 规模 增删行数(不计黄金快照与运行记录)不超过 autonomy.toml [size] max_lines 5. 运行记录 实现某份任务书的 PR(run_check.py):提交都带 `Task:`、有格式完整且 exit 为 ok 的运行记录、 - 已完成的 build 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 -读不到标签、议题或 build 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → + 已完成的 CI 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 +读不到标签、议题或 CI 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → Disable workflow(docs/specs/delivery-harness.md §8)。 K3 按 PR 编号哈希每 audit_every 个抽 1 个,合并后由工作流开 audit 议题。 @@ -30,7 +30,8 @@ from pathlib import Path from engine.checks import r1_checks, taskbook -from engine.core.common import ROOT, ConfigError, commit_field, git, path_matches, setting +from engine.core import events +from engine.core.common import ROOT, RULES_PATH, ConfigError, ci_workflows, commit_field, git, path_matches, setting from engine.routing import risk, run_check CONTRACT_PATTERNS = ["docs/plans/task-*.md", "docs/templates/**", "docs/plans/backlog.md", "docs/specs/**"] @@ -208,18 +209,21 @@ def escapes_in_window(klass: str, window: int, gh=_gh) -> tuple[int, int]: def branch_rounds(branch: str, gh=_gh) -> int | None: + """该分支已完成的 CI 轮次:ci_workflows 各工作流的 pull_request 运行,按不同的 head 提交合计。""" try: - runs = json.loads(gh("run", "list", "--workflow", "build", "--branch", branch, "--limit", "100", - "--json", "headSha,status,event")) + runs = [] + for workflow in ci_workflows(): + runs += json.loads(gh("run", "list", "--workflow", workflow, "--branch", branch, "--limit", "100", + "--json", "headSha,status,event")) except (RuntimeError, json.JSONDecodeError, OSError): return None return run_check.ci_rounds([run for run in runs if run.get("event") == "pull_request"]) def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dict | None = None, gh=_gh, - branch: str = "") -> Facts: + branch: str = "", trace: str | None = None) -> Facts: autonomy = autonomy or r1_checks.load_autonomy() - report = risk.classify(base, head, cwd) + report = risk.classify(base, head, cwd, trace_id=trace) shas = git("rev-list", f"{base}..{head}", cwd=cwd).split() defects = any(commit_field(sha, "Defect", cwd) for sha in shas) facts = Facts(report, machine_class(report, defects)) @@ -239,6 +243,71 @@ def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dic return facts +def _event_trace(pr: int | None, branch: str) -> str | None: + """事件 trace:--branch 优先;其次有 PR 时取 PR API 的 headRefName(不读 PR 正文);否则 current_trace()。 + + 只在事件开启时解析;查询失败回退 current_trace(),不改变原有判定与调用序列。 + """ + if not events.enabled(): + return None + if branch: + return branch + if pr is not None: + try: + name = json.loads(_gh("pr", "view", str(pr), "--json", "headRefName")).get("headRefName") + if name: + return str(name) + except (RuntimeError, json.JSONDecodeError, KeyError, TypeError, OSError): + pass + return events.current_trace() + + +def _config_refs() -> list[dict]: + """main 上 autonomy.toml 与 rules.toml 的带提交引用(路径@HEAD + 内容 sha256);单侧失败只少记一条。""" + refs = [] + for kind, path in (("autonomy", r1_checks.AUTONOMY_PATH), ("rules", RULES_PATH)): + entry = _config_ref(kind, path) + if entry: + refs.append(entry) + return refs + + +def _config_ref(kind: str, path: Path) -> dict | None: + """单个配置文件的带提交引用;读取或定位失败返回 None(观察旁路)。""" + try: + content = path.read_bytes() + rev = git("rev-parse", "HEAD", cwd=path.parent) + return {"kind": kind, "ref": f"{path.relative_to(path.parents[2]).as_posix()}@{rev}", + "sha256": hashlib.sha256(content).hexdigest(), "size": len(content)} + except Exception: # noqa: BLE001 观察旁路:引用准备失败只少记这条引用 + return None + + +def _emit_route(trace: str | None, facts: Facts, rules: list[Rule], auto: bool, audit: bool) -> None: + """路由观察事件(C1:facts、每条规则、result);事件不进判定,配置引用读取失败只少记引用。""" + if not events.enabled(): + return + try: + refs = _config_refs() + except Exception: # noqa: BLE001 观察旁路:引用准备失败不影响判定与输出 + refs = [] + events.emit("route", "facts", "ok", trace_id=trace, inputs=refs, + outputs={"risk": facts.risk.label, "machine_class": facts.machine_class, + "declared_class": facts.declared_class, "changed_lines": facts.changed_lines, + "escapes": facts.escapes, "window": facts.window}) + for rule in rules: + events.emit("route", rule.name, "ok" if rule.ok else "fail", trace_id=trace, inputs=refs, + outputs={"ok": rule.ok}, + decision={"by": "policy", "rule": rule.name, "reason": rule.reason}) + try: + approval = platform_outputs()["approval"] + except Exception: # noqa: BLE001 观察旁路:批准方式配置读不到就不记这个字段 + approval = None + events.emit("route", "result", "ok" if auto else "deny", trace_id=trace, inputs=refs, + outputs={"auto_merge": auto, "audit": audit, "approval": approval}, + decision={"by": "policy", "rule": "result", "reason": "自动合并" if auto else "转用户评审"}) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -248,11 +317,13 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--github", action="store_true", help="写 GITHUB_STEP_SUMMARY 与 GITHUB_OUTPUT") args = parser.parse_args(argv) autonomy = r1_checks.load_autonomy() - facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch) + trace = _event_trace(args.pr, args.branch) + facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch, trace=trace) rules = decide(facts, autonomy) auto = all(rule.ok for rule in rules) every = autonomy.get("classes", {}).get(facts.machine_class, {}).get("audit_every", 0) audit = auto and args.pr is not None and audit_sampled(args.pr, every) + _emit_route(trace, facts, rules, auto, audit) text = render(rules, facts, audit) print(text) print(risk.render_markdown(facts.risk)) diff --git a/.harness/engine/routing/risk.py b/.harness/engine/routing/risk.py index 0ea614b..45a9d77 100644 --- a/.harness/engine/routing/risk.py +++ b/.harness/engine/routing/risk.py @@ -24,6 +24,7 @@ from pathlib import Path from engine.checks import r1_checks, taskbook +from engine.core import events from engine.core.common import ( ROOT, added_line_count, @@ -76,24 +77,32 @@ def label(self) -> str: return f"R{self.level}" -def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path) -> tuple[FileRisk, str | None]: +def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path, + *, trace_id: str | None = None) -> tuple[FileRisk, str | None]: + """判定单个文件的等级;trace_id 非空时附带写逐文件观察事件(路径、命中规则、等级),不影响返回值。""" risk = rules["risk"] + + def record(rule: str, item: FileRisk, flag: str | None) -> tuple[FileRisk, str | None]: + events.emit("route", "risk.file", "ok", trace_id=trace_id, + outputs={"path": item.path, "status": item.status, "level": item.level}, + decision={"by": "risk", "rule": rule, "reason": item.reason}) + return item, flag + if path_matches(path, risk["golden"]): if status == "A": - return FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None - return FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`" + return record("golden", FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None) + return record("golden", FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`") if path_matches(path, risk["tests"]): if status == "A": - return FileRisk(path, status, 0, "新增测试"), None + return record("tests", FileRisk(path, status, 0, "新增测试"), None) if status == "D": - return FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`" + return record("tests", FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`") removed = removed_line_count(base, head, path, cwd) if removed == 0: - return FileRisk(path, status, 0, "只在已有测试文件中追加"), None - return ( - FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), - f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认", - ) + return record("tests", FileRisk(path, status, 0, "只在已有测试文件中追加"), None) + return record("tests", + FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), + f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认") shrinking = ( status == "M" and path_matches(path, risk.get("shrink_only", [])) @@ -101,22 +110,22 @@ def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd and removed_line_count(base, head, path, cwd) ) if shrinking: - return FileRisk(path, status, 0, "只能缩减的清单被缩减"), None + return record("shrink_only", FileRisk(path, status, 0, "只能缩减的清单被缩减"), None) pattern = path_matches(path, risk["r3"]) if pattern: - return FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None) pattern = path_matches(path, risk.get("contracts", [])) if pattern: - return FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None + return record(pattern, FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None) if path_matches(path, risk.get("taskbooks", [])): - return classify_taskbook(status, path, head, cwd), None + return record("taskbook", classify_taskbook(status, path, head, cwd), None) pattern = path_matches(path, risk["r0"]) if pattern: - return FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None) pattern = path_matches(path, risk["r2"]) if pattern: - return FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None - return FileRisk(path, status, 2, "未归类路径按 R2"), None + return record(pattern, FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None) + return record("unclassified", FileRisk(path, status, 2, "未归类路径按 R2"), None) def classify_taskbook(status: str, path: str, head: str, cwd: Path) -> FileRisk: @@ -147,11 +156,13 @@ def commits_claim_r1(base: str, head: str, cwd: Path) -> bool: return True -def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None = None) -> RiskReport: +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()(旧调用仍有效)。""" rules = rules or load_rules() report = RiskReport() for status, path in changed_files(base, head, cwd): - file_risk, flag = classify_file(status, path, base, head, rules, cwd) + file_risk, flag = classify_file(status, path, base, head, rules, cwd, trace_id=trace_id) report.files.append(file_risk) if flag: report.flags.append(flag) @@ -193,9 +204,21 @@ def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None report.r1_violations = strengthened report.notes.append("提交声明了 `Risk: R1`,但机器核对不满足(见上方标记、非代码路径或下列理由)→ 维持原等级") report.notes += [f"- {reason}" for reason in strengthened] + _emit_summary(report, base, head, trace_id) return report +def _emit_summary(report: RiskReport, base: str, head: str, trace_id: str | None) -> None: + """风险汇总观察事件:最终等级、R1 声明与是否被降级、被改动的已有测试数(观察旁路,不影响判定)。""" + changed_tests = sum(1 for item in report.files if item.reason.startswith("改动已有测试")) + events.emit("route", "risk.summary", "ok", trace_id=trace_id, + inputs=[events.ref("rev", base), events.ref("rev", head)], + outputs={"risk": report.label, "level": report.level, "claimed_r1": report.claimed_r1, + "downgraded": report.claimed_r1 and report.level != 1, + "changed_tests": changed_tests}, + decision={"by": "risk", "rule": "risk", "reason": f"{report.label}:{POLICY[report.level]}"}) + + def render_markdown(report: RiskReport) -> str: lines = [f"### 风险等级:**{report.label}** — {POLICY[report.level]}", ""] if report.flags: diff --git a/.harness/engine/routing/run_check.py b/.harness/engine/routing/run_check.py index 5d02ad7..5a08b95 100644 --- a/.harness/engine/routing/run_check.py +++ b/.harness/engine/routing/run_check.py @@ -9,9 +9,9 @@ 归属 范围内每个非合并提交都带 `Task: <该任务书的编号>` 运行记录 新增了 docs/runs/<任务书名>/<序号>.json;序号最大的一份格式完整、task/class/branch 与任务书一致、 exit 为 ok,提示词文件存在且 sha256 与记录一致 - CI 轮次 该分支已完成的 build 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds + CI 轮次 该分支已完成的 CI 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds -权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);build 的 harness job 只把结果写进 summary。 +权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);CI 的 harness job 只把结果写进 summary。 bin/harness run-check --base origin/main [--head HEAD] [--branch task/005-x] [--github] """ @@ -131,7 +131,7 @@ def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> def ci_rounds(runs: list[dict]) -> int: - """已完成的 build 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" + """已完成的 CI 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" return len({run.get("headSha") or run.get("head_sha") for run in runs if run.get("status") == "completed" and (run.get("headSha") or run.get("head_sha"))}) @@ -139,7 +139,7 @@ def ci_rounds(runs: list[dict]) -> int: def check_ci(rounds: int | None, header: dict) -> Finding: limit = (header.get("budget") or {}).get("ci_rounds") if rounds is None: - return Finding("CI 轮次", False, "读不到该分支的 build 运行,按超预算处理") + return Finding("CI 轮次", False, "读不到该分支的 CI 运行,按超预算处理") if not isinstance(limit, int): return Finding("CI 轮次", False, "任务书没有 budget.ci_rounds") ok = rounds <= limit diff --git a/tests/test_harness_dispatch.py b/tests/test_harness_dispatch.py index 2d3c84f..3c4dd0a 100644 --- a/tests/test_harness_dispatch.py +++ b/tests/test_harness_dispatch.py @@ -135,7 +135,7 @@ def add_label(self, pr, label): def create_issue(self, title, body, labels): self.issues.append((title, body, labels)) - def wait_ci(self, branch, sha, timeout): + def wait_ci(self, branch, sha, timeout, detail=None): ok = self.ci.pop(0) if len(self.ci) > 1 else self.ci[0] return ok, "" if ok else "CI 未通过:test_mod.Case" diff --git a/tests/test_harness_review_independent.py b/tests/test_harness_review_independent.py index 7d9e55a..a662962 100644 --- a/tests/test_harness_review_independent.py +++ b/tests/test_harness_review_independent.py @@ -84,7 +84,7 @@ def argv(self, prompt, workspace, output): return [sys.executable, "-c", code] def read(self, stdout, output): - return stdout, "fake-model" + return stdout, "fake-model", "reported" with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {"GH_TOKEN": "secret"}): verdict, model, _ = review.run_reviewer(Fake(), Path(tmp), 60) @@ -101,7 +101,7 @@ def test_pi_reviewer_is_read_only_and_parses_events(self): {"type": "message_end", "message": {"role": "assistant", "model": "glm-5.3", "content": [ {"type": "thinking", "thinking": "…"}, {"type": "text", "text": '意见\n{"verdict": "通过", "findings": []}'}]}}, ] - text, model = review.PiReviewer().read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) + text, model, *_ = review.PiReviewer().read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) self.assertEqual((review.parse_output(text).verdict, model), ("通过", "glm-5.3")) self.assertEqual(review.make_reviewer("pi").argv("p", Path("/w"), Path("/o"))[-2], "zai-coding-cn/glm-5.3") @@ -114,7 +114,7 @@ def test_opencode_reviewer_is_read_only_and_parses_events(self): {"type": "text", "part": {"type": "text", "text": "意见"}}, {"type": "text", "part": {"type": "text", "text": '```json\n{"verdict": "不通过", "findings": [{"severity": "严重"}]}\n```'}}, ] - text, model = review.OpenCodeReviewer("m").read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) + text, model, *_ = review.OpenCodeReviewer("m").read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) verdict = review.parse_output(text) self.assertEqual((verdict.verdict, verdict.flagged, model), ("不通过", True, "m")) self.assertIsInstance(review.make_reviewer("opencode"), review.OpenCodeReviewer) @@ -136,7 +136,7 @@ def argv(self, prompt, workspace, output): return [sys.executable, "-c", code] def read(self, stdout, output): - return stdout, "m" + return stdout, "m", "reported" with tempfile.TemporaryDirectory() as tmp: verdict, _, _ = review.run_reviewer(ReadsStdin(), Path(tmp), 30) @@ -144,7 +144,7 @@ def read(self, stdout, output): def test_claude_output_parsing(self): stdout = json.dumps({"result": '好\n{"verdict": "通过", "findings": []}', "modelUsage": {"claude-x": {}}}) - text, model = review.ClaudeReviewer().read(stdout, Path("/none")) + text, model, *_ = review.ClaudeReviewer().read(stdout, Path("/none")) self.assertEqual(model, "claude-x") self.assertEqual(review.parse_output(text).verdict, "通过") @@ -187,7 +187,7 @@ def argv(self, prompt, workspace, output): return [sys.executable, "-c", f"import sys; print({text!r}); sys.exit({code})"] def read(self, stdout, output): - return stdout, "m" + return stdout, "m", "reported" def test_error_exit_is_a_failure_not_a_verdict(self): quota = "ERROR: You've hit your usage limit. Try again at 12:51 AM." From 37edf60c2bc2d8d9356f357e84e64a01f02fb085 Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:03:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?harness=EF=BC=9A=E5=86=85=E7=BD=AE=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=8D=87=E7=BA=A7=E5=88=B0=2038754f8=EF=BC=88B46=20P1?= =?UTF-8?q?=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 | 167 +++++++-- .harness/engine/agents/dispatch_host.py | 20 + .../engine/agents/dispatch_observation.py | 337 +++++++++++++++++ .harness/engine/agents/review.py | 83 ++++- .harness/engine/checks/base_tests.py | 12 + .harness/engine/checks/evidence.py | 21 +- .harness/engine/checks/hygiene.py | 21 +- .harness/engine/checks/integrity.py | 87 ++++- .harness/engine/checks/mutate.py | 14 + .harness/engine/checks/quality.py | 27 +- .harness/engine/checks/taskbook.py | 78 +++- .harness/engine/checks/verify.py | 52 ++- .harness/engine/cli.py | 58 ++- .harness/engine/core/common.py | 8 + .harness/engine/core/events.py | 344 ++++++++++++++++++ .harness/engine/core/events_db.py | 333 +++++++++++++++++ .harness/engine/guards/command_guard.py | 235 +++++++++--- .harness/engine/guards/git_guard.py | 78 ++-- .harness/engine/guards/shell_structure.py | 29 ++ .harness/engine/reports/metrics.py | 33 +- .harness/engine/reports/weekly.py | 8 +- .harness/engine/routing/policy.py | 87 ++++- .harness/engine/routing/risk.py | 61 +++- .harness/engine/routing/run_check.py | 32 +- 25 files changed, 2029 insertions(+), 200 deletions(-) create mode 100644 .harness/engine/agents/dispatch_observation.py create mode 100644 .harness/engine/core/events.py create mode 100644 .harness/engine/core/events_db.py create mode 100644 .harness/engine/guards/shell_structure.py diff --git a/.harness/engine.lock b/.harness/engine.lock index 9af9978..17fba46 100644 --- a/.harness/engine.lock +++ b/.harness/engine.lock @@ -1,6 +1,6 @@ { "engine": "delivery-harness", "version": "0.1.0", - "commit": "f7b79913a04206c2ff9452f540bcd1211096ff54", - "tree": "sha256:c5900fe320c95e8d98c2e2663d602bc629a1e5352add91647b29fed67ed32702" + "commit": "2908a9aed239cc888d7e809e58ae7a211c96e6f4", + "tree": "sha256:4ad2d218b92a138c9309115e7edfd169891f1adac0bdb8983d6b677500647076" } diff --git a/.harness/engine/agents/dispatch.py b/.harness/engine/agents/dispatch.py index ab1a256..98f0795 100644 --- a/.harness/engine/agents/dispatch.py +++ b/.harness/engine/agents/dispatch.py @@ -15,6 +15,8 @@ 不能完成时升级:已开 PR 的在 PR 上评论,否则开议题,都加 escalation 标签;升级包预填当前状态、 已尝试的方案与证据,执行方写的「可选方案」「需要决定的问题」(build/dispatch/escalation.md)附在后面。 运行记录只存结构化摘要,完整事件流留在本机 /dispatch/runs/,不入库。 +各步骤的实际结果另由 engine/agents/dispatch_observation.py 写观察事件(B46 T105):观察是旁路, +不改变本文件的判定、预算与返回码。 bin/dispatch run docs/plans/task-005-x.md [--resume] [--model M] bin/dispatch status @@ -39,11 +41,14 @@ from pathlib import Path from engine.agents import dispatch_host -from engine.checks import taskbook -from engine.core.common import ENGINE_DIR, ROOT, git, load_rules, setting +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 PROMPT_TEMPLATE = ENGINE_DIR / "prompts" / "dispatch_prompt.md" ESCALATION_FILE = "build/dispatch/escalation.md" +CI_QUERY_ATTEMPTS = 3 # 等 CI 时对 gh 查询的最多尝试次数 +CI_QUERY_RETRY_SECONDS = 5 def agent_identity() -> tuple[str, str]: @@ -104,6 +109,7 @@ class Attempt: usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) executor_note: str = "" + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) # ---- 1 准入与 2 认领 ---- @@ -112,20 +118,26 @@ def admit(path: str, root: Path = ROOT, require_on_main: bool = True) -> Task: rel = Path(path).as_posix().removeprefix("./") reports = [report for report in taskbook.check_all(root) if report.path == rel] if not reports: + observation.admit(rel, root, problems=[f"{rel} 不是 docs/plans/task-*.md 下的任务书"]) raise Stop(f"{rel} 不是 docs/plans/task-*.md 下的任务书") report = reports[0] if rel in taskbook.load_exempt(root / ".harness" / "state" / "taskbook-exempt.txt"): - raise Stop(f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发") + problem = f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发" + observation.admit(rel, root, problems=[problem]) + raise Stop(problem) if require_on_main: problem = taskbook.on_main(rel, root) if problem: report.errors.append(problem) if report.errors: + observation.admit(rel, root, problems=report.errors) raise Stop("准入未通过:\n" + "\n".join(f" - {error}" for error in report.errors)) header = report.header slug = Path(rel).stem.removeprefix("task-") - return Task(rel, header["task"], header["class"], header["risk"], header["budget"], + task = Task(rel, header["task"], header["class"], header["risk"], header["budget"], header.get("spec_refs") or [], f"task/{slug}") + observation.admit(rel, root, task=task) + return task # ---- 3 槽位 ---- @@ -174,6 +186,7 @@ def acquire_slot(root: Path, config: Config, task: Task) -> tuple[int, Path]: json.dump({"pid": os.getpid(), "task": task.id, "branch": task.branch, "started_at": _now()}, handle) return index, slot_path(root, config, index) + observation.slot(task.branch, None, problem=f"{config.slots} 个槽位都在使用中") raise Stop(f"{config.slots} 个槽位都在使用中:稍后再派发,或用 bin/dispatch status 查看") @@ -205,7 +218,7 @@ def prepare_slot(root: Path, slot: Path, branch: str, resume: bool) -> None: # ---- 4 守卫 ---- -def prepare_guard(root: Path) -> tuple[Path, str]: +def prepare_guard(root: Path, trace: str = "") -> tuple[Path, str]: """把 origin/main 上的守卫导出到本机目录并确认可运行;返回 (扩展路径, main 的提交)。""" ref = git("rev-parse", "origin/main", cwd=root) bundle = state_dir(root) / "guard" / ref @@ -218,6 +231,7 @@ def prepare_guard(root: Path) -> tuple[Path, str]: paths = ["harness", ".pi"] if legacy else [".harness", ".pi"] archive = subprocess.run(["git", "archive", ref, *paths], cwd=root, capture_output=True, check=False) if archive.returncode != 0: + observation.guard_preflight(trace, ref, False) raise Stop(f"无法从 origin/main 导出守卫:{archive.stderr.decode(errors='replace').strip()}") subprocess.run(["tar", "-x", "-C", str(bundle)], input=archive.stdout, check=True) extension = bundle / ".pi" / "extensions" / "harness-guard.ts" @@ -225,7 +239,9 @@ def prepare_guard(root: Path) -> tuple[Path, str]: [sys.executable, *guard_cmd, "--format", "json", "--role", "implementer"], input=json.dumps({"command": "gh issue close 1"}), capture_output=True, text=True, check=False, ) - if not extension.exists() or probe.returncode != 2: + denied = extension.exists() and probe.returncode == 2 + observation.guard_preflight(trace, ref, denied) + if not denied: raise Stop("守卫预检失败:导出的守卫没有拒绝必拒绝的样例,停止派发") return extension, ref @@ -239,24 +255,25 @@ def render_prompt(task: Task, minutes: int, feedback: str) -> str: branch=task.branch, minutes=minutes, feedback=extra) -def verify_signature(slot: Path, config: Config) -> tuple[bool, str, str]: - """运行 verify;返回 (通过, 失败摘要, 失败签名)。签名去掉数字与路径,用于识别打转。""" +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) + log = result.stdout + result.stderr if result.returncode == 0: - return True, "", "" + return True, "", "", log summary_file = slot / "build" / "verify" / "summary.json" failed, excerpts = [], [] if summary_file.exists(): for item in json.loads(summary_file.read_text()).get("results", []): if item.get("status") == "fail": failed.append(item["name"]) - log = slot / item.get("log", "") - tail = log.read_text(errors="replace").splitlines()[-15:] if log.is_file() else [] + log_path = slot / item.get("log", "") + tail = log_path.read_text(errors="replace").splitlines()[-15:] if log_path.is_file() else [] excerpts.append(f"### {item['name']}\n" + "\n".join(tail)) text = "\n\n".join(excerpts) or (result.stdout + result.stderr)[-2000:] normalized = re.sub(r"\d+(\.\d+)?", "#", re.sub(r"/\S+/", "/…/", text)) signature = ",".join(failed) + ":" + hashlib.sha256(normalized.encode()).hexdigest()[:12] - return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature + return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature, log def worktree_problem(slot: Path, base: str) -> str | None: @@ -291,7 +308,12 @@ def run_executor(self, task: Task, slot: Path, guard: Path, prompt: str, events: ) self.used_seconds += seconds model, usage, denials = self.host.parse(events) - return dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials) + counts = observation.tool_counts(self.host, events) + result = dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials, + counts["guard_allowed"]) + observation.executor_round(task.branch, slot, prompt, events, result, + {"name": self.host.name, "version": self.host.version()}, counts) + return result def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace_dir: Path) -> tuple[Attempt, list[str]]: """5 与 6:执行方 → verify,失败带摘要重试;返回本次尝试与每轮提示词。""" @@ -309,9 +331,11 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace attempt.usage[key] = round(attempt.usage.get(key, 0) + value, 6) 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 note = slot / ESCALATION_FILE if note.exists(): attempt.executor_note = note.read_text(encoding="utf-8")[:4000] + observation.clarify(task.branch, note) attempt.exit = "clarify" return attempt, prompts if result.exit != "ok": @@ -319,14 +343,16 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace return attempt, prompts problem = worktree_problem(slot, base) if problem: - ok, summary, signature = False, problem, problem + ok, summary, signature, log = False, problem, problem, "" else: - ok, summary, signature = verify_signature(slot, self.config) + ok, summary, signature, log = verify_signature(slot, self.config) + repeat = bool(attempt.signatures) and attempt.signatures[-1] == signature + observation.local_verify(task.branch, slot, base, ok, signature, log, repeat) if ok: attempt.ok, attempt.exit = True, "ok" return attempt, prompts attempt.verify_summary = summary - if attempt.signatures and attempt.signatures[-1] == signature: + if repeat: attempt.signatures.append(signature) attempt.exit = "loop" return attempt, prompts @@ -383,20 +409,25 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr def run(self, path: str, resume: bool = False) -> int: task = admit(path, self.root) if self.github.remote_branch_exists(task.branch) and not resume: + observation.claim(task.branch, False) raise Stop(f"远端已有 {task.branch}:该任务已被认领(续跑加 --resume)") - guard, guard_ref = prepare_guard(self.root) + guard, guard_ref = prepare_guard(self.root, task.branch) index, slot = acquire_slot(self.root, self.config, task) try: prepare_slot(self.root, slot, task.branch, resume) + observation.slot(task.branch, index, git("rev-parse", "HEAD", cwd=slot), + git("rev-parse", "origin/main", cwd=self.root)) if not resume: self.github.push(slot, task.branch) # 分支即认领 + observation.claim(task.branch, True) return self._loop(task, index, slot, guard, guard_ref) finally: release_slot(self.root, index) def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) -> int: runs = state_dir(self.root) / "runs" / task.id - records = slot / "docs" / "runs" / task.path.split("/")[-1].removesuffix(".md") + folder = task.path.split("/")[-1].removesuffix(".md") + records = slot / "docs" / "runs" / folder number = len(list(records.glob("*.json"))) + 1 if records.exists() else 1 pr, ci_rounds, feedback = None, 0, "" while True: @@ -409,11 +440,14 @@ def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) self.escalate(task, pr, attempt, f"本地未完成({EXIT_TEXT.get(attempt.exit, attempt.exit)})") return 1 if pr is None: - pr = self.github.open_pr(slot, task.branch, f"{task.id}:{_title(self.root, task)}", - pr_body(task, attempt, number, prompt_sha)) + body = pr_body(task, attempt, number, prompt_sha, root=self.root) + pr = self.github.open_pr(slot, task.branch, _pr_title(self.root, task), body) + observation.push_pr(task.branch, slot, pr, f"docs/runs/{folder}/{number}.json", body) ci_rounds += 1 - ok, summary = self.github.wait_ci(task.branch, git("rev-parse", "HEAD", cwd=slot), - self.config.ci_timeout_seconds) + head = git("rev-parse", "HEAD", cwd=slot) + detail: dict = {} + ok, summary = self.github.wait_ci(task.branch, head, self.config.ci_timeout_seconds, detail) + observation.ci_wait(task.branch, pr, head, ci_rounds, ok, summary, detail.get("run_ids")) if ok: print(f"{task.id}:PR #{pr} 的 CI 通过,合并由 auto-merge 判定") return 0 @@ -426,10 +460,16 @@ def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) def escalate(self, task: Task, pr: int | None, attempt: Attempt, reason: str) -> None: body = escalation_body(task, attempt, reason) - if pr is not None: - self.github.comment(pr, body, label="escalation") - else: - self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + target = "pr" if pr is not None else "issue" + sent = False + try: + if pr is not None: + self.github.comment(pr, body, label="escalation") + else: + self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + sent = True + finally: + observation.escalate(task.branch, pr, reason, target, body, sent) # 评论失败也留事件 print(f"{task.id}:{reason},已升级") @@ -445,7 +485,24 @@ def _title(root: Path, task: Task) -> str: return first[1].splitlines()[0].removeprefix("任务:").strip() if len(first) > 1 else task.path -def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str) -> str: +def _pr_title(root: Path, task: Task) -> str: + """PR 标题:任务书标题已带「TXXX:」前缀时不再重复拼接(B65)。""" + title = _title(root, task) + return title if title.startswith(f"{task.id}:") else f"{task.id}:{title}" + + +def _manual_section(root: Path, task: Task) -> str: + """人工验收一节正文:验收表存在「人工」类证据行时给指引,否则写「无」(B57)。 + + 判定与 `bin/harness acceptance --manual` 同源(acceptance.Item.manual)。 + """ + items = acceptance.parse_spec(root / task.path, root) + if any(item.manual for item in items): + return "见任务书验收表中的人工条目(`bin/harness acceptance --manual`)。" + return "无" + + +def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str, root: Path = ROOT) -> str: usage = attempt.usage return "\n".join([ "## 任务", "", @@ -465,7 +522,7 @@ def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str) -> str: "- CI 运行(当前 head):见本 PR checks", "- 风险等级与修复证据:见 harness job summary(机器生成)", "", "## 需要人工验收的部分", "", - "见任务书验收表中的人工条目(`bin/harness acceptance --manual`)。", "", + _manual_section(root, task), "", "🤖 Dispatched by bin/dispatch", ]) + "\n" @@ -517,10 +574,12 @@ def open_pr(self, slot: Path, branch: str, title: str, body: str) -> int: "--body-file", "-"], cwd=slot, agent=True, stdin=body) return int(url.rstrip("/").rsplit("/", 1)[-1]) - def comment(self, pr: int, body: str, label: str | None = None) -> None: - self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) + def comment(self, pr: int, body: str, label: str | None = None) -> str: + """评论 PR 并返回评论 URL(供观察事件记录;标签逻辑不变)。""" + url = self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) if label: self.add_label(pr, label) + return url def _ensure_label(self, label: str) -> None: """不存在才创建;不用 --force,已有标签(含登记用标签)不被改写。""" @@ -541,19 +600,47 @@ def create_issue(self, title: str, body: str, labels: list[str]) -> None: argv += ["--label", label] self._run(argv, agent=True, stdin=body) - def wait_ci(self, branch: str, sha: str, timeout: float) -> tuple[bool, str]: + def _ci_runs(self, workflow: str, branch: str) -> list[dict]: + """查一个工作流在该分支上的运行。gh 瞬时失败(如网络断开)连续 CI_QUERY_ATTEMPTS 次才抛出,避免一次瞬断中止整个派发。""" + for attempt in range(1, CI_QUERY_ATTEMPTS + 1): + try: + return json.loads(self._run(["gh", "run", "list", "--workflow", workflow, "--branch", branch, + "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) + except (RuntimeError, json.JSONDecodeError): + if attempt == CI_QUERY_ATTEMPTS: + raise + time.sleep(CI_QUERY_RETRY_SECONDS) + return [] + + def wait_ci(self, branch: str, sha: str, timeout: float, detail: dict | None = None) -> tuple[bool, str]: + """等 rules.toml [dispatch] ci_workflows 列出的每个工作流在该提交上跑完;全部成功才算通过。 + + detail 非 None 时写入 run_ids(本次结论对应的 Actions 运行 ID),供派发的 ci_wait 观察事件 + 使用;返回值与 gh 调用序列保持不变。 + """ + workflows = ci_workflows() deadline = time.monotonic() + timeout while time.monotonic() < deadline: - runs = json.loads(self._run(["gh", "run", "list", "--workflow", "build", "--branch", branch, - "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) - run = next((item for item in runs if item["headSha"] == sha), None) - if run and run["status"] == "completed": - if run["conclusion"] == "success": - return True, "" - log = subprocess.run(["gh", "run", "view", str(run["databaseId"]), "--log-failed"], cwd=self.root, + found = {} + for workflow in workflows: + runs = self._ci_runs(workflow, branch) + run = next((item for item in runs if item["headSha"] == sha), None) + if run and run["status"] == "completed": + found[workflow] = run + failed = next((run for run in found.values() if run["conclusion"] != "success"), None) + if failed: + log = subprocess.run(["gh", "run", "view", str(failed["databaseId"]), "--log-failed"], cwd=self.root, capture_output=True, text=True, check=False).stdout - return False, f"CI 未通过:{run['url']}\n\n```\n{log[-3000:]}\n```" + if detail is not None: + detail["run_ids"] = [failed["databaseId"]] + return False, f"CI 未通过:{failed['url']}\n\n```\n{log[-3000:]}\n```" + if len(found) == len(workflows): + if detail is not None: + detail["run_ids"] = sorted(run["databaseId"] for run in found.values()) + return True, "" time.sleep(30) + if detail is not None: + detail["run_ids"] = [] return False, f"等待 CI 超过 {int(timeout / 60)} 分钟" @@ -609,7 +696,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "status": return status() if args.command == "review": - import review as review_module # review 依赖本模块,按需导入 + from engine.agents import review as review_module # review 依赖本模块,按需导入 if args.watch: return review_module.watch(args.interval, args.reviewer) diff --git a/.harness/engine/agents/dispatch_host.py b/.harness/engine/agents/dispatch_host.py index 0a0dc6c..a525698 100644 --- a/.harness/engine/agents/dispatch_host.py +++ b/.harness/engine/agents/dispatch_host.py @@ -33,6 +33,7 @@ class RunResult: model: str = "" usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) def executor_env(base: dict[str, str], identity: dict[str, str], gh_config_dir: Path) -> dict[str, str]: @@ -156,3 +157,22 @@ def parse(self, events: Path) -> tuple[str, dict, dict[str, int]]: if "cost" in result: result["cost"] = round(result["cost"], 6) return model, result, dict(denials) + + 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": []} + if not events.exists(): + return counts + for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "tool_execution_end": + continue + content = (event.get("result") or {}).get("content") or [] + 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 diff --git a/.harness/engine/agents/dispatch_observation.py b/.harness/engine/agents/dispatch_observation.py new file mode 100644 index 0000000..9610dc7 --- /dev/null +++ b/.harness/engine/agents/dispatch_observation.py @@ -0,0 +1,337 @@ +"""派发与独立评审的观察旁路(B46 T105):组装设计 3.2 / 3.4 的事件元数据并写事件。 + +共用合同 C0/C1 与任务书 F1:观察是旁路——本模块的任何失败(事件写入、引用与产物准备、摘要组装) +都不得改变派发与评审的判定、返回码或 host/gh 调用序列;dispatch.py 与 review.py 只接线。事件里只有 +引用、哈希、计数、枚举、时长(隐私过滤由 events.emit 负责);每轮 Pi 流、verify 输出、CI 失败摘要、 +升级摘要与评审材料等原始内容只存本机产物(artifacts/),事件与审计摘要只带哈希与大小。 +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +from pathlib import Path + +from engine.core import events +from engine.core.common import git + +STAGE = "dispatch" +REVIEW_STAGE = "review" +# C6 严重度词表 → 事件输出的 ASCII 键(outputs 键只允许 [A-Za-z0-9_.-],中文键会被过滤器丢弃)。 +SEVERITIES = ("阻断", "严重", "一般", "建议") +_SEVERITY_KEYS = {"阻断": "severity.blocker", "严重": "severity.critical", "一般": "severity.major", + "建议": "severity.minor", "unknown": "severity.unknown"} + + +def _emit(stage: str, step: str, status: str, **kwargs) -> None: + """写事件并吞掉一切失败:emit 自身不抛,组装 kwargs 的异常也不能逃出观察旁路。""" + try: + events.emit(stage=stage, step=step, status=status, **kwargs) + except Exception: # noqa: BLE001 观察旁路:失败只丢弃 + return + + +def _artifact_refs(prefix: str, data: bytes) -> dict: + """本机产物引用三元字段(.sha256/.size/.ref);内容只存本机,事件只有哈希与大小。""" + refs = {f"{prefix}.sha256": hashlib.sha256(data).hexdigest(), f"{prefix}.size": len(data)} + try: + stored = events.store_artifact(data) + except Exception: # noqa: BLE001 观察旁路:产物保存失败只少记 ref + stored = None + if stored: + refs[f"{prefix}.ref"] = stored["ref"] + return refs + + +# ---- 派发(设计 3.2)---- + +def admit(rel: str, root: Path, task=None, problems: list[str] | None = None) -> None: + """1 准入:任务书 sha 与 origin/main 引用;通过与否、任务编号、类别与预算(时长/重试/CI 轮次)。""" + try: + if not events.enabled(): + return + inputs = _admit_inputs(rel, root) + if task is None: + reason = problems[0] if problems else None + _emit(STAGE, "admit", "fail", inputs=inputs, outputs={"problems": len(problems or [])}, + decision={"by": "taskbook", "rule": "admit", "reason": reason}, error={"kind": "admit"}) + return + budget = task.budget or {} + _emit(STAGE, "admit", "ok", trace_id=task.branch, inputs=inputs, + outputs={"task": task.id, "class": task.klass, "wall_clock_min": budget.get("wall_clock_min"), + "retries": budget.get("retries"), "ci_rounds": budget.get("ci_rounds")}, + decision={"by": "taskbook", "rule": "admit"}) + except Exception: # noqa: BLE001 观察旁路 + return + + +def _admit_inputs(rel: str, root: Path) -> list[dict]: + """任务书(路径@origin/main + 内容哈希)与 origin/main 提交引用;读取失败只少记。""" + inputs: list[dict] = [] + try: + rev = git("rev-parse", "origin/main", cwd=root, check=False) + data = (root / rel).read_bytes() + except Exception: # noqa: BLE001 观察旁路 + return inputs + if data: + inputs.append({"kind": "taskbook", "ref": f"{rel}@{rev}" if rev else rel, + "sha256": hashlib.sha256(data).hexdigest(), "size": len(data)}) + if rev: + inputs.append(events.ref("rev", rev)) + return inputs + + +def claim(branch: str, claimed: bool) -> None: + """2 认领:远端分支已存在为 fail(已被认领);从槽位推送新分支成功为 ok。""" + _emit(STAGE, "claim", "ok" if claimed else "fail", trace_id=branch, + inputs=[events.ref("branch", branch)], outputs={"claimed": claimed}, + decision=None if claimed else {"by": "dispatch", "rule": "claim", "reason": "已被认领"}) + + +def slot(branch: str, index: int | None, start: str | None = None, base: str | None = None, + problem: str | None = None) -> None: + """3 槽位:槽位序号与起点提交;无空闲槽位等失败也留事件。""" + outputs: dict = {"slot": index, "start": start} + if problem: + outputs["problem"] = problem + _emit(STAGE, "slot", "fail" if problem else "ok", trace_id=branch, + inputs=[events.ref("rev", base)] if base else None, outputs=outputs) + + +def guard_preflight(branch: str, guard_ref: str, denied: bool) -> None: + """4 守卫预检:必拒载荷实际被拒为 ok;导出或探针失败为 fail。""" + _emit(STAGE, "guard_preflight", "ok" if denied else "fail", trace_id=branch, + inputs=[events.ref("rev", guard_ref)] if guard_ref else None, outputs={"denied": denied}) + + +def tool_counts(host, stream: Path) -> dict: + """执行方流的工具调用计数(每次 tool_execution_end 计一次完成):守卫拒绝计 guard_denied, + 其余(成功或普通失败)计 guard_allowed。宿主缺 parse_observability 或其失败时按 0 计(观察旁路)。""" + try: + observed = host.parse_observability(stream) + return {"guard_allowed": int(observed.get("guard_allowed", 0)), + "guard_denied": int(observed.get("guard_denied", 0))} + except Exception: # noqa: BLE001 观察旁路 + return {"guard_allowed": 0, "guard_denied": 0} + + +def executor_round(branch: str, slot: Path, prompt: str, stream: Path, result, + host: dict | None = None, counts: dict | None = None) -> None: + """5 执行方每轮:宿主与版本、模型、时长、退出原因、token/费用、守卫放行与被拒工具数、 + 完整事件流的本机产物哈希。""" + try: + if not events.enabled(): + return + base = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = [events.ref("prompt", hashlib.sha256(prompt.encode("utf-8")).hexdigest())] + if base: + inputs.append(events.ref("rev", base)) + usage = result.usage or {} + outputs = {"host": (host or {}).get("name"), "host_version": (host or {}).get("version"), + "model": result.model or None, "exit": result.exit, + "gen_ai.usage.input_tokens": usage.get("input_tokens"), + "gen_ai.usage.output_tokens": usage.get("output_tokens"), "cost": usage.get("cost"), + "guard_allowed": result.guard_allowed, + "guard_denied": (counts or {}).get("guard_denied"), **_stream_refs(stream)} + _emit(STAGE, "executor_round", "ok" if result.exit == "ok" else "fail", trace_id=branch, + duration_ms=int(result.seconds * 1000), inputs=inputs, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def _stream_refs(stream: Path) -> dict: + """完整执行方流(原始字节)的本机产物引用;读取失败只少记三元字段。""" + try: + return _artifact_refs("stream", stream.read_bytes()) + except Exception: # noqa: BLE001 观察旁路 + return {} + + +def local_verify(branch: str, slot: Path, base: str, ok: bool, signature: str, log: str, + repeat: bool) -> None: + """6 本地判定每轮:通过与否、失败签名、是否打转(逐项检查结果走 verify 自己的事件); + verify 完整输出保存为本机产物,事件只有哈希与大小。""" + try: + if not events.enabled(): + return + head = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = [events.ref("rev", rev) for rev in (base, head) if rev] + outputs: dict = {"ok": ok, "signature": signature or None, "repeat": repeat} + if log: + outputs.update(_artifact_refs("log", log.encode("utf-8"))) + _emit(STAGE, "local_verify", "ok" if ok else "fail", trace_id=branch, inputs=inputs, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def clarify(branch: str, note: Path) -> None: + """执行方请求澄清:备注 sha256 与是否请求澄清;missing_context 条目数由 T202 填充(当前恒 0)。""" + try: + if not events.enabled(): + return + data = note.read_bytes() + except Exception: # noqa: BLE001 观察旁路 + return + _emit(STAGE, "clarify", "ok", trace_id=branch, + inputs=[{"kind": "note", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}], + outputs={"requested": True, "missing_context": 0}) + + +def push_pr(branch: str, slot: Path, pr: int, record_path: str, body: str) -> None: + """7 推送与开 PR(PR 实际开出后调用):运行记录 path@提交、PR 正文哈希、提交 SHA 与 PR 号。""" + try: + if not events.enabled(): + return + head = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = ([{"kind": "run_record", "ref": f"{record_path}@{head}"}] if head and record_path else []) + data = body.encode("utf-8") + if body: + inputs.append({"kind": "pr_body", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}) + outputs: dict = {"head": head, "pr": pr} + if body: + outputs.update(_artifact_refs("body", data)) + _emit(STAGE, "push_pr", "ok", trace_id=branch, inputs=inputs or None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def ci_wait(branch: str, pr: int, head: str, round_no: int, ok: bool, summary: str, + run_ids: list | None = None) -> None: + """8 等 CI 每轮:轮次、结论、Actions 运行 ID 与失败摘要哈希。""" + try: + if not events.enabled(): + return + outputs: dict = {"pr": pr, "round": round_no, "ok": ok, + "run_ids": ",".join(str(value) for value in run_ids or [])} + if summary: + outputs.update(_artifact_refs("summary", summary.encode("utf-8"))) + _emit(STAGE, "ci_wait", "ok" if ok else "fail", trace_id=branch, + inputs=[events.ref("rev", head)] if head else None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def escalate(branch: str, pr: int | None, reason: str, target: str, summary: str, sent: bool) -> None: + """升级:原因、目标(pr 评论或 issue)与标签;状态摘要只留本机产物哈希。评论失败也留事件(fail)。""" + try: + if not events.enabled(): + return + inputs = [events.ref("pr", str(pr))] if pr is not None else [] + data = summary.encode("utf-8") + if summary: + inputs.append({"kind": "summary", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}) + outputs: dict = {"reason": reason, "target": target, "pr": pr, "labels": "escalation", + "notified": sent} + if summary: + outputs.update(_artifact_refs("summary", data)) + _emit(STAGE, "escalate", "ok" if sent else "fail", trace_id=branch, + inputs=inputs or None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +# ---- 独立评审(设计 3.4 与共用合同 C6)---- + +def severity_counts(findings: list | None) -> dict: + """C6 severity_counts:阻断/严重/一般/建议/unknown 计数;缺失或未知严重度计入 unknown。""" + counts: dict = {key: 0 for key in SEVERITIES} + counts["unknown"] = 0 + for item in findings or []: + severity = item.get("severity") if isinstance(item, dict) else None + counts[severity if severity in counts else "unknown"] += 1 + return counts + + +def _engine_ref(workspace: Path) -> str: + """生成 pack 的引擎固定提交:业务仓库读 engine.lock 的 commit,否则用工作区 HEAD。""" + with contextlib.suppress(Exception): # 观察旁路:锁文件缺失或损坏时回退 HEAD + lock = json.loads((workspace / ".harness" / "engine.lock").read_text(encoding="utf-8")) + commit = str(lock.get("commit") or "").strip() + if commit: + return commit + return git("rev-parse", "HEAD", cwd=workspace, check=False) or "unknown" + + +def _material(kind: str, path: Path, ref: str, recipe: dict, *, encoding: str = "utf8", + raw_artifact: bool = False) -> dict | None: + """一份 C6 材料:评审实际读取文件的原始字节哈希;pack 原始字节转存本机产物(不上传)。 + + ref 是引用(路径@提交、比较范围、pull 引用或 none 哨兵);pack 的 ref 是内容寻址产物 sha256。 + 读取失败只少记这一份(观察旁路)。 + """ + try: + data = path.read_bytes() + except OSError: + return None + digest = hashlib.sha256(data).hexdigest() + entry = {"kind": kind, "ref": digest if raw_artifact else ref, "sha256": digest, + "size": len(data), "encoding": encoding, "recipe": recipe} + if raw_artifact: + entry["recipe"]["artifact_sha256"] = digest + with contextlib.suppress(Exception): # 转存失败不改变材料哈希 + events.store_artifact(data) + return entry + + +def review_materials(workspace: Path, base: str, head: str, taskbook_rel: str | None, + pr: int) -> list[dict]: + """C6 materials:task/diff/ci/pr/pack 五类,recipe 用固定短 ID 与参数,不存命令正文。 + + 无任务书时 task 材料是现有的「无」哨兵字节,ref 用 none 哨兵;单份材料读取失败只少记这一份。 + """ + folder = workspace / "build" / "review" + materials = [ + _material("task", folder / "task.md", f"{taskbook_rel}@{head}" if taskbook_rel else "none", + {"id": "task_v1"}), + _material("diff", folder / "diff.patch", f"{base}...{head}", {"id": "diff_v1"}), + _material("ci", folder / "ci.md", f"pull/{pr}/checks", {"id": "ci_v1"}), + _material("pr", folder / "pr.md", f"pull/{pr}", {"id": "pr_v1"}), + _material("pack", folder / "pack.md", "", {"id": "pack_v1", "engine_ref": _engine_ref(workspace), + "retention_days": 30}, + encoding="raw_bytes", raw_artifact=True), + ] + return [item for item in materials if item] + + +def review_audit(*, trace_id: str, head: str, base: str, reviewer: str, model, model_basis: str, + designer, implementers: list | None, independent, same_host: bool, parsed: bool, + verdict: str, duration_ms: int, findings: list | None, + materials: list | None) -> dict: + """C6 harness-review-audit 摘要(单行 JSON,嵌在评论标记里):不含评论自身哈希与尚未知的 URL。""" + return { + "schema_version": 1, "trace_id": trace_id, "head": head, "base": base, + "reviewer": reviewer, "model": model, "model_basis": model_basis, "designer": designer, + "implementers": list(implementers or []), "independent": independent, "same_host": same_host, + "parsed": parsed, "verdict": verdict, "duration_ms": duration_ms, + "severity_counts": severity_counts(findings), "materials": list(materials or []), + } + + +def review(*, trace: str, head: str, reviewer: str, verdict: str, duration_ms: int, + findings: list | None, materials: list | None, designer=None, model=None, + model_basis: str = "unknown", parsed: bool = True, independent=None, same_host: bool = False, + comment: str | None = None, comment_body: str = "", failure: str = "", + error_kind: str = "") -> None: + """评审观察事件:材料引用、评审方与模型、时长、结论、按严重度计数、身份是否分离、评论 URL 与 + 评论字节哈希(评论发布后);评审方自身失败记 error.kind(status error),结论不通过记 status fail。""" + try: + if not events.enabled(): + return + inputs = ([events.ref("rev", head)] if head else []) + [ + {"kind": item["kind"], "ref": item["ref"], "sha256": item["sha256"], "size": item["size"]} + for item in materials or []] + outputs: dict = {"reviewer": reviewer, "model": model, "verdict": verdict, "parsed": parsed, + "designer": designer, "independent": independent, "same_host": same_host, + **{_SEVERITY_KEYS[key]: count for key, count in severity_counts(findings).items()}} + if failure: + outputs["failure"] = failure + if comment: + outputs["comment.url"] = comment + if comment_body: + outputs.update(_artifact_refs("comment", comment_body.encode("utf-8"))) + status = "error" if error_kind else ("fail" if verdict == "不通过" else "ok") + _emit(REVIEW_STAGE, "review", status, trace_id=trace or None, duration_ms=duration_ms, + inputs=inputs or None, outputs=outputs, error={"kind": error_kind} if error_kind else None) + except Exception: # noqa: BLE001 观察旁路 + return diff --git a/.harness/engine/agents/review.py b/.harness/engine/agents/review.py index 09dcc93..7be9478 100644 --- a/.harness/engine/agents/review.py +++ b/.harness/engine/agents/review.py @@ -33,6 +33,7 @@ from typing import ClassVar from engine.agents import dispatch, dispatch_host +from engine.agents import dispatch_observation as observation from engine.core.common import ENGINE_DIR, ROOT, changed_files, commit_field, git, load_rules from engine.routing import run_check @@ -51,6 +52,9 @@ class Verdict: summary: str = "" parsed: bool = True failure: str = "" # 评审方本身失败(报错退出、超时、额度用尽):不是结论,不计入校准,不评论为结论 + error_kind: str = "" # 失败的机器类别(timeout / reviewer_exit / no_output),供评审观察事件 + audit_model: str | None = None # 审计摘要用的模型(明确报告优先,其次显式请求;两者皆无为 None) + model_basis: str = "unknown" # reported / explicit_request / unknown(共用合同 C6) @property def flagged(self) -> bool: @@ -79,13 +83,14 @@ def parse_output(text: str) -> Verdict: class Reviewer: name = "" + model_name: str | None = None # 显式请求的模型(审计摘要用;展示名可带档位等修饰) env: ClassVar[dict[str, str]] = {} # 评审方专用的环境变量(如 OpenCode 的权限配置) def argv(self, prompt: str, workspace: Path, output: Path) -> list[str]: raise NotImplementedError - def read(self, stdout: str, output: Path) -> tuple[str, str]: - """返回 (最终文本, 模型)。""" + def read(self, stdout: str, output: Path) -> tuple[str, str, str]: + """返回 (最终文本, 展示用模型, model_basis:reported / explicit_request / unknown)。""" raise NotImplementedError @@ -94,6 +99,7 @@ class CodexReviewer(Reviewer): def __init__(self, model: str | None, effort: str | None = None): self.model, self.effort = model, effort + self.model_name = model def argv(self, prompt, workspace, output): # 没配置模型时沿用 Codex 自己的默认设置。 @@ -105,7 +111,8 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): base = self.model or "codex 默认模型" label = f"{base} {self.effort}" if self.effort else base - return (output.read_text(encoding="utf-8") if output.exists() else stdout), label + basis = "explicit_request" if self.model else "unknown" + return (output.read_text(encoding="utf-8") if output.exists() else stdout), label, basis class ClaudeReviewer(Reviewer): @@ -113,6 +120,7 @@ class ClaudeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["claude", "-p", "--allowedTools", "Read,Grep,Glob", "--output-format", "json"] @@ -124,9 +132,11 @@ def read(self, stdout, output): try: data = json.loads(stdout) except json.JSONDecodeError: - return stdout, self.model or "" - model = next(iter(data.get("modelUsage") or {}), self.model or "") - return str(data.get("result", "")), model + return stdout, self.model or "", "explicit_request" if self.model else "unknown" + reported = next(iter(data.get("modelUsage") or {}), "") + model = reported or self.model or "" + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return str(data.get("result", "")), model, basis class PiReviewer(Reviewer): @@ -134,6 +144,7 @@ class PiReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["pi", "-na", "--tools", "read,grep,find,ls", "-p", "--mode", "json", "--no-session"] @@ -143,7 +154,7 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): """取最后一条助手消息的文本与模型(pi 的 json 事件流)。""" - text, model = "", self.model or "" + text, model, reported = "", self.model or "", "" for line in stdout.splitlines(): try: event = json.loads(line) @@ -154,8 +165,11 @@ def read(self, stdout, output): parts = [part.get("text", "") for part in message.get("content", []) if part.get("type") == "text"] if any(parts): text = "\n".join(parts) + if message.get("model"): + reported = message["model"] model = message.get("model", model) - return text, model + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return text, model, basis class OpenCodeReviewer(Reviewer): @@ -171,6 +185,7 @@ class OpenCodeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["opencode", "run", "--pure", "--agent", "plan", "--format", "json", "--dir", str(workspace)] @@ -189,7 +204,7 @@ def read(self, stdout, output): part = event.get("part") or {} if event.get("type") == "text" and part.get("type") == "text": texts.append(part.get("text", "")) - return "\n".join(texts), self.model or "" + return "\n".join(texts), self.model or "", "explicit_request" if self.model else "unknown" def make_reviewer(name: str) -> Reviewer: @@ -218,13 +233,20 @@ def run_reviewer(reviewer: Reviewer, workspace: Path, timeout: float) -> tuple[V result = subprocess.run(reviewer.argv(prompt, workspace, output), cwd=workspace, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout, check=False) - text, model = reviewer.read(result.stdout, output) + text, model, basis = reviewer.read(result.stdout, output) failure = "" if result.returncode == 0 else _failure_line(result.stdout + result.stderr, result.returncode) + error_kind = "" if result.returncode == 0 else "reviewer_exit" except subprocess.TimeoutExpired: - text, model, failure = "", "", f"超过 {int(timeout / 60)} 分钟未完成" + text, model, basis = "", "", "unknown" + failure, error_kind = f"超过 {int(timeout / 60)} 分钟未完成", "timeout" verdict = parse_output(text) if failure or not text.strip(): - verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出") + verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出", + error_kind=error_kind or "no_output") + # C6:模型明确报告优先;其次实际命令显式指定的请求模型;两者皆无不把配置默认值当已调用模型。 + verdict.audit_model = model if basis == "reported" else ( + reviewer.model_name if basis == "explicit_request" else None) + verdict.model_basis = basis return verdict, model, time.monotonic() - started @@ -310,7 +332,7 @@ def run_check_records(workspace: Path, base: str) -> list[dict]: def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | None, head: str, seconds: float, - same_host: bool = False) -> str: + same_host: bool = False, audit: dict | None = None) -> str: lines = [ f"### 独立评审(试行):{verdict.verdict}", "", f"- 评审方:{reviewer}({model or '模型未报告'});设计方:{designer or '未能判定'};评审的提交:`{head[:12]}`;用时 {seconds / 60:.1f} 分钟", @@ -328,6 +350,9 @@ def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | data = {"verdict": verdict.verdict, "reviewer": reviewer, "model": model, "head": head, "findings": len(verdict.findings), "flagged": verdict.flagged, "parsed": verdict.parsed} lines += ["", f""] + if audit is not None: + # C6 审计摘要:与旧标记并列,单行 JSON,字段与材料编码见共用合同 C6;T305 只解析这个新标记。 + lines.append(f"") return "\n".join(lines) + "\n" @@ -335,9 +360,11 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= github = github or dispatch.GitHub(root) pr = json.loads(github._run(["gh", "pr", "view", str(number), "--json", "title,body,headRefName,headRefOid,baseRefName,state,mergeCommit"])) + trace = pr["headRefName"] # 评审事件的显式 trace:PR 的 headRefName + head = pr["headRefOid"] workspace = review_workspace(root) git("fetch", "--quiet", "origin", f"pull/{number}/head", "main", cwd=workspace) - checkout(workspace, pr["headRefOid"]) + checkout(workspace, head) base = review_base(pr, workspace) designer = designer_of(base, "HEAD", pr["headRefName"], workspace) name = reviewer_name or load_rules().get("review", {}).get("reviewer") or OTHER.get(designer or "") @@ -350,14 +377,36 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= 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 "" 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) + 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( "timeout_minutes", 30) * 60) if verdict.failure: + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=name in executors, + failure=verdict.failure, error_kind=verdict.error_kind) print(f"PR #{number}:评审方失败({verdict.failure}),没有评论,保留待评审标签") return 1 - executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} - github.comment(number, render_comment(verdict, name, model, designer, pr["headRefOid"], seconds, - same_host=name in executors)) + same_host = name in executors + audit = observation.review_audit(trace_id=trace, head=head, base=base, reviewer=name, + model=verdict.audit_model, model_basis=verdict.model_basis, + designer=designer, + implementers=sorted(item for item in executors if item), + independent=independent, same_host=same_host, parsed=verdict.parsed, + verdict=verdict.verdict, duration_ms=int(seconds * 1000), + findings=verdict.findings, materials=materials) + body = render_comment(verdict, name, model, designer, head, seconds, same_host, audit) + url = github.comment(number, body) + # 事件在评论实际发布后写:URL 与评论字节哈希只有此刻可知(C6:摘要不预写 URL、不含自身哈希)。 + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=same_host, + comment=url, comment_body=body) try: github._run(["gh", "pr", "edit", str(number), "--remove-label", LABEL], agent=True) except RuntimeError: diff --git a/.harness/engine/checks/base_tests.py b/.harness/engine/checks/base_tests.py index 7d400d7..10688fa 100644 --- a/.harness/engine/checks/base_tests.py +++ b/.harness/engine/checks/base_tests.py @@ -23,6 +23,7 @@ import tempfile from pathlib import Path +from engine.core import events from engine.core.common import ROOT, clean_git_env, git from engine.routing import risk @@ -73,6 +74,16 @@ def run(base: str, head: str = "HEAD", cwd: Path = ROOT) -> tuple[bool, bool, st return completed.returncode == 0, not intended or completed.returncode == TAMPERED, summary +def _record(base: str, head: str, ok: bool, enforced: bool, summary: str) -> None: + """观察旁路:一条 base_tests 事件(是否通过、是否强制、安全摘要行),失败不影响原判定。""" + try: + events.emit(stage="ci", step="base_tests", status="ok" if ok else "fail", + inputs=[events.ref("rev", base), events.ref("rev", head)], + outputs={"ok": ok, "enforced": enforced, "summary": summary or None}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -80,6 +91,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--repo", type=Path, default=ROOT, help="要检查的仓库(默认本仓库)") args = parser.parse_args(argv) ok, enforced, summary = run(args.base, args.head, cwd=args.repo) + _record(args.base, args.head, ok, enforced, summary) # 观察:结果、强制与摘要行 if ok: print(f"✓ base 版本的已有测试在 head 代码上全部通过({summary})") return 0 diff --git a/.harness/engine/checks/evidence.py b/.harness/engine/checks/evidence.py index 064696d..2bbf164 100644 --- a/.harness/engine/checks/evidence.py +++ b/.harness/engine/checks/evidence.py @@ -33,6 +33,7 @@ from pathlib import Path from engine.checks import verify +from engine.core import events from engine.core.cases import load_project_cases from engine.core.common import ROOT, clean_git_env, commit_field, git, path_matches @@ -386,6 +387,21 @@ def render_markdown(evidences: dict[str, DefectEvidence], base: str, head: str, return "\n".join(lines) + "\n" +def _record(evidences: dict[str, DefectEvidence]) -> None: + """观察旁路:每个缺陷编号一条事件(有无提交、是否改代码、修复前后结果),不含缺陷正文。""" + try: + for evidence in evidences.values(): + events.emit(stage="ci", step="evidence", status="ok" if evidence.ok else "fail", + outputs={"defect": evidence.defect, "commits": len(evidence.commits), + "doc_only": evidence.doc_only, "code_files": len(evidence.code_files), + "tests": len(evidence.python_tests) + len(evidence.swift_refs), + "before": evidence.before or None, "after": evidence.after or None, + "withdrawn": bool(evidence.withdrawn_by), + "problems": len(evidence.problems), "warnings": len(evidence.warnings)}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -398,10 +414,11 @@ def main(argv: list[str] | None = None) -> int: ids = [item for item in (args.ids or "").split(",") if item] evidences = analyse( - args.base, args.head, ids or None, run_tests=not args.no_run, swift=args.swift + args.base, args.head, ids or None, run_tests=not args.no_run, swift=args.swift, cwd=ROOT ) + _record(evidences) # 观察:每个缺陷的提交、代码改动与修复前后结果 coverage = replay_coverage(evidences) - report = render_markdown(evidences, args.base, args.head) + "\n" + render_replay(coverage) + report = render_markdown(evidences, args.base, args.head, ROOT) + "\n" + render_replay(coverage) print(report) if args.markdown: with open(args.markdown, "a") as handle: diff --git a/.harness/engine/checks/hygiene.py b/.harness/engine/checks/hygiene.py index 56c287f..cbc225d 100644 --- a/.harness/engine/checks/hygiene.py +++ b/.harness/engine/checks/hygiene.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from pathlib import Path +from engine.core import events from engine.core.common import ROOT, git, load_rules, parse_added_lines, path_matches @@ -141,6 +142,18 @@ def scan_range(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | No return check_paths(changed, rules) + check_sizes(sizes, rules) + check_added_lines(parse_added_lines(diff), rules) +def _record(scope: str, violations: list[Violation]) -> None: + """观察旁路:每条违规一条事件(路径与规则名,不含命中内容),汇总一条(范围与条数)。""" + try: + for violation in violations: + events.emit(stage="ci", step="hygiene.violation", status="fail", + outputs={"path": violation.path, "rule": violation.kind}) + events.emit(stage="ci", step="hygiene.summary", status="fail" if violations else "ok", + outputs={"scope": scope, "violations": len(violations)}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) scope = parser.add_mutually_exclusive_group() @@ -149,13 +162,15 @@ def main(argv: list[str] | None = None) -> int: scope.add_argument("--range", metavar="BASE", help="BASE...HEAD 的改动") args = parser.parse_args(argv) + scope_name = "staged" if args.staged else f"range:{args.range}" if args.range else "tracked" if args.staged: - violations = scan_staged() + violations = scan_staged(cwd=ROOT) elif args.range: - violations = scan_range(args.range) + violations = scan_range(args.range, cwd=ROOT) else: - violations = scan_tracked() + violations = scan_tracked(cwd=ROOT) + _record(scope_name, violations) # 观察:违规路径与规则名、范围与条数(不含命中内容) for violation in violations: print(violation.render()) if violations: diff --git a/.harness/engine/checks/integrity.py b/.harness/engine/checks/integrity.py index 48a9dad..9a0bb0e 100644 --- a/.harness/engine/checks/integrity.py +++ b/.harness/engine/checks/integrity.py @@ -6,6 +6,8 @@ bin/harness integrity 核对(verify 各档运行) bin/harness integrity --print 打印当前目录树哈希 + +核对本身是一条观察事件(设计 3.3):lock 引用、目录摘要、文件计数与失败签名,写入失败不影响原判定。 """ from __future__ import annotations @@ -15,22 +17,34 @@ import json from pathlib import Path -from engine.core.common import ENGINE_DIR, HARNESS_DIR +from engine.core import events +from engine.core.common import ENGINE_DIR, HARNESS_DIR, git LOCK_FILE = HARNESS_DIR / "engine.lock" IGNORED_PARTS = {"__pycache__"} IGNORED_SUFFIXES = {".pyc", ".pyo"} -def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: - """按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256;忽略字节码缓存。""" +def _scan(engine_dir: Path) -> tuple[str, dict[str, str]]: + """一次遍历同时得到目录树哈希与逐文件哈希(同一过滤:忽略字节码缓存)。 + + 哈希算法与既有实现逐字节一致:按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256。 + """ digest = hashlib.sha256() + hashes: dict[str, str] = {} for path in sorted(p for p in engine_dir.rglob("*") if p.is_file()): rel = path.relative_to(engine_dir) if IGNORED_PARTS & set(rel.parts) or path.suffix in IGNORED_SUFFIXES: continue - digest.update(rel.as_posix().encode() + b"\0" + hashlib.sha256(path.read_bytes()).hexdigest().encode() + b"\n") - return f"sha256:{digest.hexdigest()}" + file_hash = hashlib.sha256(path.read_bytes()).hexdigest() + digest.update(rel.as_posix().encode() + b"\0" + file_hash.encode() + b"\n") + hashes[rel.as_posix()] = file_hash + return f"sha256:{digest.hexdigest()}", hashes + + +def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: + """目录树哈希(install 写锁与 integrity 核对共用同一算法)。""" + return _scan(engine_dir)[0] def read_lock(path: Path = LOCK_FILE) -> dict: @@ -42,7 +56,10 @@ def write_lock(path: Path, version: str, commit: str, tree: str) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") -def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[bool, str]: +def check(engine_dir: Path | None = None, lock_file: Path | None = None) -> tuple[bool, str]: + """核对引擎目录与锁文件;参数缺省取当前安装位置(延迟绑定,便于隔离测试与工具复用)。""" + engine_dir = ENGINE_DIR if engine_dir is None else engine_dir + lock_file = LOCK_FILE if lock_file is None else lock_file if engine_dir.parent != lock_file.parent: return True, "引擎不在业务仓库的 .harness/ 下(引擎仓库自身),无需核对" if not lock_file.exists(): @@ -58,6 +75,63 @@ def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[b return True, f"引擎 {lock.get('version')}({str(lock.get('commit', ''))[:12]})与锁文件一致 ✓" +def _changed_count(engine_dir: Path) -> int | None: + """引擎目录里未提交的变更文件数(含未跟踪)。 + + 锁文件只有整体摘要,无法逐文件回溯;未提交改动是「改了引擎而没走 upgrade」的主要形态, + 以 git HEAD 为基线给出被改文件数(不含内容)。git 不可用时返回 None。 + """ + try: + out = git("status", "--porcelain", "--untracked-files=all", "--", ".", + cwd=engine_dir, check=True, isolate=True) + except RuntimeError: + return None + return sum(1 for line in out.splitlines() if line.strip()) + + +def _metadata() -> tuple[list[dict], dict, str | None]: + """事件的 lock 引用、目录摘要、文件计数与失败签名;各项准备失败只让对应字段缺失。""" + inputs: list[dict] = [] + try: + inputs.append(events.file_ref("lock", LOCK_FILE)) + except OSError: + pass + signature = None if LOCK_FILE.exists() else "lock_missing" + outputs: dict = {} + try: + tree, hashes = _scan(ENGINE_DIR) + except OSError: + return inputs, outputs, signature + outputs["tree"] = tree + outputs["files"] = len(hashes) + changed = _changed_count(ENGINE_DIR) + if changed is not None: + outputs["changed"] = changed + try: + lock = read_lock(LOCK_FILE) + except Exception: # noqa: BLE001 缺锁或坏锁:事件只缺对应字段,不改变原判定 + lock = {} + if lock.get("tree"): + outputs["lock.tree"] = lock["tree"] + if signature is None and lock["tree"] != tree: + signature = "tree_mismatch" + return inputs, outputs, signature + + +def _record(ok: bool) -> None: + """观察旁路:一条 integrity 内容事件(stage=verify);准备失败不得影响原判定与输出。""" + try: + if ENGINE_DIR.parent != LOCK_FILE.parent: # 引擎仓库自身:核对不适用 + events.emit(stage="verify", step="integrity", status="skip") + return + inputs, outputs, signature = _metadata() + events.emit(stage="verify", step="integrity", status="ok" if ok else "fail", + inputs=inputs or None, outputs=outputs or None, + error=None if ok else {"kind": "integrity_mismatch", "signature": signature}) + except Exception: # noqa: BLE001 观察失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--print", action="store_true", help="打印当前目录树哈希") @@ -66,6 +140,7 @@ def main(argv: list[str] | None = None) -> int: print(tree_hash()) return 0 ok, message = check() + _record(ok) # 观察:lock 引用、目录摘要、变更文件计数;失败 error.kind=integrity_mismatch print(message) return 0 if ok else 1 diff --git a/.harness/engine/checks/mutate.py b/.harness/engine/checks/mutate.py index c83e5dc..a581329 100644 --- a/.harness/engine/checks/mutate.py +++ b/.harness/engine/checks/mutate.py @@ -25,6 +25,7 @@ from pathlib import Path from engine.checks.replay import copy_worktree +from engine.core import events from engine.core.common import ROOT, STATE_DIR, changed_files, clean_git_env, setting BASELINE = STATE_DIR / "mutation-baseline.json" @@ -174,6 +175,17 @@ def below_baseline(score: float, previous: float | None) -> bool: return previous is not None and round(score, 4) < previous +def _record(result: dict, previous: float | None, failed: bool) -> None: + """观察旁路:每个变异目标一条事件(目标、得分与基线),失败不影响原判定。""" + try: + events.emit(stage="ci", step="mutation", status="fail" if failed else "ok", + outputs={"target": result["name"], "total": result["total"], "killed": result["killed"], + "score": result["score"], "baseline": previous, + "error": result.get("error") or None}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) mode = parser.add_mutually_exclusive_group() @@ -221,6 +233,8 @@ def main(argv: list[str] | None = None) -> int: failed = True print(f" ✗ 得分低于基线 {previous:.0%}") result["score"] = round(score, 4) + _record(result, previous, # 观察:目标、得分与基线 + bool(result.get("error")) or (args.check and below_baseline(score, previous))) if args.update: for result in results: if not result.get("error"): diff --git a/.harness/engine/checks/quality.py b/.harness/engine/checks/quality.py index 1966875..8c0e463 100644 --- a/.harness/engine/checks/quality.py +++ b/.harness/engine/checks/quality.py @@ -29,7 +29,8 @@ import sys from pathlib import Path -from engine.core.common import ROOT, STATE_DIR, setting +from engine.core import events +from engine.core.common import ROOT, STATE_DIR, git, setting from engine.lang.swift import functions as swift_functions BASELINE = STATE_DIR / "quality-baseline.json" @@ -107,12 +108,34 @@ def compare(current: dict[str, int], baseline: dict[str, int]) -> list[str]: ] +def _baseline_inputs() -> list[dict] | None: + """基线引用(路径@提交 + 内容哈希);读取或定位失败只少记引用。""" + try: + return [events.file_ref("baseline", BASELINE, rev=git("rev-parse", "HEAD", cwd=ROOT, check=False))] + except Exception: # noqa: BLE001 观察旁路:引用准备失败只少记这条引用 + return None + + +def _record(current: dict[str, int], baseline: dict[str, int], inputs: list[dict] | None) -> None: + """观察旁路:每个指标一条事件(当前值、基线、是否棘轮),回归即 fail;失败不影响原判定。""" + try: + for name, value in current.items(): + regressed = name in RATCHETED and name in baseline and value > baseline[name] + events.emit(stage="ci", step="quality", status="fail" if regressed else "ok", + inputs=inputs, + outputs={"metric": name, "value": value, "baseline": baseline.get(name), + "ratcheted": name in RATCHETED}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--update", action="store_true", help="指标不高于基线时写回基线") args = parser.parse_args(argv) - current = measure() + current = measure(ROOT) baseline = json.loads(BASELINE.read_text()) if BASELINE.exists() else {} + _record(current, baseline, _baseline_inputs()) # 观察:各指标当前值/基线与回归 for name, value in current.items(): kind = "棘轮" if name in RATCHETED else "报告" print(f"{name:<20} {value:>6}(基线 {baseline.get(name, '—')},{kind})") diff --git a/.harness/engine/checks/taskbook.py b/.harness/engine/checks/taskbook.py index e4752ec..754d904 100644 --- a/.harness/engine/checks/taskbook.py +++ b/.harness/engine/checks/taskbook.py @@ -40,6 +40,7 @@ from pathlib import Path from engine.checks import acceptance +from engine.core import events from engine.core.common import ROOT, STATE_DIR, git, load_rules, path_matches TASK_GLOB = "task-*.md" @@ -63,7 +64,12 @@ REQUIRED = ["task", "class", "risk", "designer", "size", "architecture", "budget", "rollback"] NEW_REF = re.compile(r"^新增[::]\s*(?P[^#\s]+)#(?P[A-Z]{1,3}\d+)$") NO_SPEC = re.compile(r"^不挂规格[::]\s*(?P\S.*)$") -PATH_TOKEN = re.compile(r"`([A-Za-z0-9_.*-]+(?:/[A-Za-z0-9_.*-]+)+|[A-Za-z0-9_-]+\.(?:py|swift|md|toml|txt|json))`") +# 步骤里的文件路径:多级路径、根目录单点与多点文件名(README.zh-CN.md,B61)都完整识别, +# 否则多点文件名被截断、不参与类别/风险的交叉核对。 +PATH_TOKEN = re.compile( + r"`([A-Za-z0-9_.*-]+(?:/[A-Za-z0-9_.*-]+)+" + r"|[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*\.(?:py|swift|md|toml|txt|json))`" +) class HeaderError(ValueError): @@ -363,12 +369,79 @@ def on_main(path: str, root: Path = ROOT) -> str | None: return None +# 问题文案前缀 → 稳定规则键(观察侧归類用,不参与判定;顺序即优先级,前缀长的在前)。 +_PROBLEM_RULES = ( + ("class 取值非法", "header.class"), + ("class ", "header.class_risk"), + ("spec_refs 中的", "acceptance.link"), + ("spec_refs", "header.spec_refs"), + ("步骤触及护栏", "steps.class"), + ("步骤触及架构级路径", "steps.architecture"), + ("步骤触及 R3 路径", "steps.risk"), + ("步骤跨", "steps.architecture"), + ("头部缺少字段", "header.required"), + ("task ", "header.task"), + ("designer", "header.designer"), + ("size", "header.size"), + ("architecture", "header.architecture"), + ("budget", "header.budget"), + ("rollback", "header.rollback"), + ("「验收」表为空", "acceptance.table"), + ("验收第", "acceptance.row"), + ("验收表挂了", "acceptance.link"), + ("缺少「目标终态」", "section.goal"), + ("中、大任务缺少", "section.required"), + ("不是 docs/plans", "path"), +) + + +def _problem_rule(error: str) -> str: + """观察旁路:把问题文案归到稳定的规则键(只用于事件计数,不参与判定;未识别的归 other)。""" + for prefix, rule in _PROBLEM_RULES: + if error.startswith(prefix): + return rule + if "origin/main" in error: + return "on_main" + if "taskbook-exempt.txt" in error: + return "exempt" + return "other" + + +def _admission_inputs(path: str, root: Path, rev: str) -> list[dict]: + """任务书引用(路径@提交 + 内容哈希);文件读不到时只带路径,不带哈希。""" + target = root / path + if not target.is_file(): + return [events.ref("taskbook", path)] + try: + return [events.file_ref("taskbook", target, rev=rev or None)] + except OSError: + return [events.ref("taskbook", path)] + + +def _record_admission(reports: list[Report], root: Path = ROOT) -> None: + """观察旁路:taskbook.admit 每份任务书一条事件(通过与问题规则计数),失败不影响原判定。""" + try: + rev = git("rev-parse", "HEAD", cwd=root, check=False) + for report in reports: + counts: dict[str, int] = {} + for error in report.errors: + rule = _problem_rule(error) + counts[rule] = counts.get(rule, 0) + 1 + events.emit(stage="ci", step="taskbook.admit", status="ok" if not report.errors else "fail", + inputs=_admission_inputs(report.path, root, rev), + outputs={"problems": len(report.errors), **counts}, + decision={"by": "taskbook", "rule": "admit", + "reason": report.errors[0] if report.errors else "合格"}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("paths", nargs="*", help="只检查这些任务书(默认全部)") parser.add_argument("--on-main", action="store_true", help="另要求与 origin/main 上的版本一致(派发前)") args = parser.parse_args(argv) - reports = check_all() + reports = check_all(ROOT) if args.paths: wanted = {Path(path).as_posix().removeprefix("./") for path in args.paths} reports = [report for report in reports if report.path in wanted] @@ -384,6 +457,7 @@ def main(argv: list[str] | None = None) -> int: for report in failed: for error in report.errors: print(f"✗ {report.path}:{error}") + _record_admission(reports, ROOT) # 观察:引用、通过与问题规则计数 return 1 if failed else 0 diff --git a/.harness/engine/checks/verify.py b/.harness/engine/checks/verify.py index 9e7e0bf..1c11229 100644 --- a/.harness/engine/checks/verify.py +++ b/.harness/engine/checks/verify.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import platform @@ -22,6 +23,7 @@ import time from dataclasses import dataclass, field +from engine.core import events from engine.core.common import CLI, ENGINE_REL, ROOT, clean_git_env, git, setting LOG_DIR = ROOT / "build" / "verify" @@ -48,6 +50,7 @@ class Result: log: str = "" note: str = "" tail: list[str] = field(default_factory=list) + code: int | None = None # 检查命令的退出码;未运行(--skip、strict 转换)为 None def pinned_version(package: str) -> str | None: @@ -153,6 +156,49 @@ def build_checks(strict: bool = False) -> list[Check]: return [available[name] for name in names] +# 最终 Result(含 --skip 与 --strict 转换)到事件状态的映射(设计 3.3)。 +STATUS_BY_RESULT = {"pass": "ok", "fail": "fail", "skip": "skip"} + + +def _log_refs(log: str) -> dict: + """完整日志按原始字节算哈希并存为本机产物;读取或保存失败时省略对应字段,不影响判定。""" + if not log: + return {} + try: + data = (ROOT / log).read_bytes() + except OSError: + return {} + refs = {"log.sha256": hashlib.sha256(data).hexdigest(), "log.size": len(data)} + try: + stored = events.store_artifact(data) + except Exception: # noqa: BLE001 观察准备失败不影响原判定 + stored = None + if stored: + refs["log.ref"] = stored["ref"] + return refs + + +def emit_result(result: Result, info: dict, tier: str) -> None: + """每项检查的最终 Result 之后写 verify.(D029):head、档位、退出码、时长、失败签名与日志哈希。""" + try: + events.emit(stage="verify", step=f"verify.{result.name}", status=STATUS_BY_RESULT[result.status], + duration_ms=int(result.seconds * 1000), + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"exit": result.code, "signature": result.note or None, **_log_refs(result.log)}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + +def emit_summary(info: dict, tier: str, ok: bool) -> None: + """收尾写一条 verify.summary(D030):档位、通过与工作区状态。""" + try: + events.emit(stage="verify", step="verify.summary", status="ok" if ok else "fail", + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"tier": tier, "ok": ok, "dirty": info["dirty"]}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + def run_check(check: Check) -> Result: if check.requires == "macos" and not _is_macos(): return Result(check.name, "skip", note=check.why_skipped) @@ -179,9 +225,9 @@ def run_check(check: Check) -> Result: seconds = time.monotonic() - started relative = str(target.relative_to(ROOT)) if code == 0: - return Result(check.name, "pass", seconds, relative) + return Result(check.name, "pass", seconds, relative, code=code) tail = target.read_text(errors="replace").splitlines()[-TAIL_LINES:] - return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail) + return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail, code=code) def head_info() -> dict: @@ -237,6 +283,7 @@ def main(argv: list[str] | None = None) -> int: if result.status == "skip" and args.strict: result = Result(check.name, "fail", note=f"--strict 下不允许跳过({result.note})") results.append(result) + emit_result(result, info, tier_name) # 最终 Result(含 strict 转换)之后埋点,--skip 与 strict 都不漏 mark = {"pass": "✓", "fail": "✗", "skip": "-"}[result.status] extra = f" {result.note}" if result.note else "" print(f"{mark} {result.name:<15} {result.seconds:5.1f}s{extra}", flush=True) @@ -259,6 +306,7 @@ def main(argv: list[str] | None = None) -> int: "ok": not failed, } (LOG_DIR / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + emit_summary(info, tier_name, not failed) write_step_summary(results, info) if args.json: print(json.dumps(summary, ensure_ascii=False, indent=2)) diff --git a/.harness/engine/cli.py b/.harness/engine/cli.py index ea8dbd9..fd12862 100644 --- a/.harness/engine/cli.py +++ b/.harness/engine/cli.py @@ -7,6 +7,7 @@ import importlib import sys +import time from pathlib import Path # 让 `engine` 包可导入:引擎目录的上一级(业务仓库的 .harness/,或引擎仓库根)。 @@ -47,11 +48,52 @@ "upgrade": "engine.core.install:upgrade_main", } +# 入口事件的阶段映射(共用合同 C1):判定入口归 verify,判级与路由归 route,派发归 dispatch, +# 评审归 review;表外的其余检查、报告与安装升级归 ci。新增入口必须落进这张表或登记例外。 +COMMAND_STAGE = { + "verify": "verify", + "integrity": "verify", + "risk": "route", + "policy": "route", + "dispatch": "dispatch", + "review": "review", + "review-pack": "review", +} +# 不记通用入口事件的命令:guard 只记拒绝(T104,放行不逐条记,设计 3.6);events/trace 查询、 +# audit/alert 发布各自记事件,避免递归(C1)。 +QUIET_COMMANDS = frozenset({"guard-command", "guard-git", "events", "trace", "audit", "alert"}) + def usage() -> str: return "用法:cli.py <子命令> [参数…]\n子命令:" + "、".join(COMMANDS) +def _record_dispatch(name: str, started: float, outcome) -> None: + """分派边界的观察旁路:一条 cli.<命令> 事件(时长、返回码或异常类别);永不影响原行为。 + + 观察失败(含 events 导入失败,如引擎副本损坏)静默丢弃:原 SystemExit/业务异常/返回码 + 与 stderr 逐字不变(共用合同 C0 三态等价)。 + """ + if name in QUIET_COMMANDS: + return + try: + from engine.core import events # 延迟导入:钩子等高频路径不付不必要的导入成本 + + if isinstance(outcome, SystemExit): + code = outcome.code if isinstance(outcome.code, int) else (0 if outcome.code is None else 1) + status = "ok" if code == 0 else "fail" + error = {"kind": "SystemExit"} if code else None + elif isinstance(outcome, BaseException): + code, status, error = None, "error", {"kind": type(outcome).__name__} + else: + code, status, error = outcome, ("ok" if outcome == 0 else "fail"), None + events.emit(stage=COMMAND_STAGE.get(name, "ci"), step=f"cli.{name}", status=status, + duration_ms=int((time.monotonic() - started) * 1000), + outputs={"code": code}, error=error) + except Exception: # noqa: BLE001 设计要求:观察失败不得影响原行为,也不得向 stderr 写提示 + return + + def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv if not args or args[0] in {"-h", "--help"}: @@ -62,9 +104,19 @@ def main(argv: list[str] | None = None) -> int: print(f"未知子命令:{name}\n{usage()}", file=sys.stderr) return 2 module_name, _, function = COMMANDS[name].partition(":") - module = importlib.import_module(module_name) - sys.argv = [f"cli.py {name}", *rest] - return getattr(module, function or "main")(rest) + started = time.monotonic() + try: + module = importlib.import_module(module_name) + sys.argv = [f"cli.py {name}", *rest] + code = getattr(module, function or "main")(rest) + except SystemExit as exc: + _record_dispatch(name, started, exc) # 仅记录,原样重抛 + raise + except BaseException as exc: + _record_dispatch(name, started, exc) # 业务异常同样记录后原样重抛 + raise + _record_dispatch(name, started, code) + return code if __name__ == "__main__": diff --git a/.harness/engine/core/common.py b/.harness/engine/core/common.py index f588516..295ad98 100644 --- a/.harness/engine/core/common.py +++ b/.harness/engine/core/common.py @@ -123,6 +123,14 @@ def setting(section: str, key: str, default=None, *, required: bool = False, sou return default +def ci_workflows() -> list[str]: + """判定 CI 通过与统计 CI 轮次所依据的工作流名(rules.toml [dispatch] ci_workflows,缺省 ["harness"],即模板的工作流)。""" + names = setting("dispatch", "ci_workflows", ["harness"], source="rules") + if not isinstance(names, list) or not names or not all(isinstance(name, str) and name for name in names): + raise ConfigError(".harness/config/rules.toml 的 [dispatch] ci_workflows 须是非空的工作流名列表") + return names + + # 目录约定(与项目无关的通用布局,写在引擎 README): # docs/specs/ 现役规格与验收表 docs/plans/task-*.md 任务书 # docs/runs/ 运行记录 docs/templates/ 模板 diff --git a/.harness/engine/core/events.py b/.harness/engine/core/events.py new file mode 100644 index 0000000..8d36c18 --- /dev/null +++ b/.harness/engine/core/events.py @@ -0,0 +1,344 @@ +"""事件公共 API(可观测性):只包住已有的结构化结果,永不影响调用方。 + +事件不进任何判定(设计 2.5):emit / set_anchor 永不抛异常,失败向 stderr 提示一次(同一进程内) +后跳过;HARNESS_EVENTS=off 或 checks.toml [events] enabled = false 时关闭,环境变量优先。 +值只放引用、哈希、计数、枚举、时长:写入前强制隐私过滤(设计 2.4),只丢弃、不改写、不截断。 +""" + +from __future__ import annotations + +import hashlib +import os +import re +import sys +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path + +from engine import __version__ as ENGINE_VERSION +from engine.core import events_db +from engine.core.common import git, setting + +STAGES = ("guard", "verify", "dispatch", "ci", "route", "review", "merge", "alert") +STATUSES = ("ok", "fail", "skip", "deny", "error") +ACTOR_ROLES = ("designer", "implementer", "reviewer", "approver", "engine") + +_MAX_STR = 120 +_MAX_REASON = 200 +_MAX_REF = 200 +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") +_FORBIDDEN_PREFIXES = ("/Users/", "/home/") +_DROP = object() + +_warned = False + + +def _warn_once() -> None: + """同一进程内只向 stderr 提示一次事件写入失败。""" + global _warned + if not _warned: + _warned = True + print("harness:事件写入失败,已跳过(不影响本次运行)", file=sys.stderr) + + +def enabled() -> bool: + """事件开关:HARNESS_EVENTS=off 关闭;其次 checks.toml [events] enabled = false 关闭;缺省开启。""" + env = os.environ.get("HARNESS_EVENTS") + if env is not None: + return env.strip().lower() not in ("", "0", "off", "false") + try: + return setting("events", "enabled", True) is not False + except Exception: # noqa: BLE001 配置损坏时按缺省处理 + return True + + +def default_source() -> str: + """事件来源:真实 Actions(run_id/run_attempt/job 三键齐全)为 + "ci:::"(来源链隔离);缺键的 CI 环境兼容返回 "ci";否则 "local"。""" + if os.environ.get("CI") == "true": + run_id = os.environ.get("GITHUB_RUN_ID") + run_attempt = os.environ.get("GITHUB_RUN_ATTEMPT") + job = os.environ.get("GITHUB_JOB") + if run_id and run_attempt and job: + return f"ci:{run_id}:{run_attempt}:{job}" + return "ci" + return "local" + + +def _host_class(source: str) -> str: + """actor.host 缺省值:链来源的显示分类(隔离后的 ci::… 仍显示为 "ci",其余沿用 source)。""" + return "ci" if source.startswith("ci") else source + + +def _short_head() -> str: + return git("rev-parse", "--short=7", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) or "unknown" + + +def current_trace() -> str: + """追踪 ID:分支名;detached HEAD 为 HEAD@<7 位提交>;在 main 上为 main@<7 位提交>。 + + CI 中优先取 GITHUB_HEAD_REF(PR 分支),其次 GITHUB_REF_NAME(为 main 时同样写 main@<7 位提交>)。 + """ + if os.environ.get("CI") == "true": + head_ref = os.environ.get("GITHUB_HEAD_REF") + if head_ref: + return head_ref + ref_name = os.environ.get("GITHUB_REF_NAME") + if ref_name: + return f"main@{_short_head()}" if ref_name == "main" else ref_name + branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) + if branch in ("HEAD", "main"): + return f"{branch}@{_short_head()}" + return branch or "unknown" + + +def ref(kind: str, ref: str, *, sha256: str | None = None, size: int | None = None) -> dict: + """输入输出引用(值是引用而非内容):sha256、size 为 None 的键省略。""" + out = {"kind": kind, "ref": ref} + if sha256 is not None: + out["sha256"] = sha256 + if size is not None: + out["size"] = size + return out + + +def file_ref(kind: str, path: Path, rev: str | None = None) -> dict: + """文件引用:读内容算 sha256 与大小,ref 为仓库相对路径(POSIX),rev 非空时写成 <路径>@。""" + path = Path(path) + content = path.read_bytes() + try: + relative = path.resolve().relative_to(events_db.ROOT.resolve()).as_posix() + except ValueError: + relative = path.as_posix() + if rev: + relative = f"{relative}@{rev}" + digest = hashlib.sha256(content).hexdigest() + return {"kind": kind, "ref": relative, "sha256": digest, "size": len(content)} + + +def store_artifact(data: bytes | Path) -> dict | None: + """内容寻址保存到 artifacts/(已存在则不重复写)并登记 artifacts 表,返回引用字典。 + + Path 读取或 bytes 转换失败同样不抛异常:提示一次并返回 None;调用方把 None 放进 inputs + 时会被引用过滤器丢弃并计入 redacted,不产生半条引用。 + """ + try: + content = Path(data).read_bytes() if isinstance(data, Path) else bytes(data) + except Exception: # noqa: BLE001 设计要求:观察失败不得影响调用方 + _warn_once() + return None + digest = hashlib.sha256(content).hexdigest() + entry = {"kind": "artifact", "ref": digest, "sha256": digest, "size": len(content)} + try: + if enabled(): + events_db.save_artifact(digest, len(content), content) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + return entry + + +def _clean_str(value: str, limit: int) -> str | None: + """合规字符串原样返回;超长、形似本机路径、含换行的返回 None(丢弃,不截断)。""" + if len(value) > limit: + return None + if value.startswith(_FORBIDDEN_PREFIXES) or "C:\\" in value or "\n" in value or "\r" in value: + return None + return value + + +def _clean_scalar(value, limit: int = _MAX_STR): + """只允许 int、float、bool、None 与合规 str;其余(嵌套 dict/list 等)丢弃。""" + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + cleaned = _clean_str(value, limit) + return cleaned if cleaned is not None else _DROP + return _DROP + + +def _clean_role(value) -> str | object: + return value if value in ACTOR_ROLES else _DROP + + +def _filter_outputs(outputs) -> tuple[dict, int]: + """outputs 必须是扁平字典:键 [A-Za-z0-9_.-]{1,64},值合规标量;其余丢弃。""" + if not outputs: + return {}, 0 + if not isinstance(outputs, dict): + return {}, 1 + clean, dropped = {}, 0 + for key, value in outputs.items(): + cleaned = _clean_scalar(value) if isinstance(key, str) and _KEY_RE.match(key) else _DROP + if cleaned is _DROP: + dropped += 1 + else: + clean[key] = cleaned + return clean, dropped + + +def _filter_known(mapping, limits: dict[str, int], validators: dict[str, Callable] | None = None) -> tuple[dict, int]: + """按已定键集合过滤 dict(decision/error/actor/引用);未知键与不合规值丢弃。""" + if not mapping: + return {}, 0 + if not isinstance(mapping, dict): + return {}, 1 + clean, dropped = {}, 0 + for key, value in mapping.items(): + if key not in limits: + dropped += 1 + continue + if validators and key in validators: + cleaned = validators[key](value) + else: + cleaned = _clean_scalar(value, limits[key]) + if cleaned is _DROP: + dropped += 1 + else: + clean[key] = cleaned + return clean, dropped + + +def _filter_inputs(inputs) -> tuple[list[dict], int]: + """inputs 是引用列表;非 dict 的元素与元素内不合规的键值丢弃。""" + if not inputs: + return [], 0 + if not isinstance(inputs, list): + return [], 1 + clean, dropped = [], 0 + for item in inputs: + item_clean, item_dropped = _filter_known(item, {"kind": _MAX_STR, "ref": _MAX_REF, + "sha256": _MAX_STR, "size": _MAX_STR}) + if any(value is not None for value in item_clean.values()): + clean.append(item_clean) + dropped += item_dropped + else: + dropped += max(item_dropped, 1) # 过滤后为空的引用整条丢弃(不入表、不进哈希),至少计 1 + return clean, dropped + + +_DECISION_LIMITS = {"by": _MAX_STR, "rule": _MAX_STR, "reason": _MAX_REASON} +_ERROR_LIMITS = {"kind": _MAX_STR, "signature": _MAX_STR} +_ACTOR_LIMITS = {"role": _MAX_STR, "host": _MAX_STR, "model": _MAX_STR} + + +def _build_payload(stage: str, step: str, status: str, source: str, trace_id: str, + duration_ms, outputs, decision, error, actor, inputs) -> tuple[dict, list[dict]]: + """隐私过滤后组装存储层需要的列值(redacted 为被丢弃的键值累计)。""" + clean_outputs, n_outputs = _filter_outputs(outputs) + clean_decision, n_decision = _filter_known(decision, _DECISION_LIMITS) + clean_error, n_error = _filter_known(error, _ERROR_LIMITS) + clean_actor, n_actor = _filter_known(actor, _ACTOR_LIMITS, {"role": _clean_role}) + clean_inputs, n_inputs = _filter_inputs(inputs) + return { + "source": source, "trace_id": trace_id, "stage": stage, "step": step, "status": status, + "duration_ms": duration_ms, + "actor_role": clean_actor.get("role"), "actor_host": clean_actor.get("host"), + "model": clean_actor.get("model"), + "decision_by": clean_decision.get("by"), "decision_rule": clean_decision.get("rule"), + "decision_reason": clean_decision.get("reason"), + "error_kind": clean_error.get("kind"), "error_signature": clean_error.get("signature"), + "outputs": clean_outputs, "engine_version": ENGINE_VERSION, + "redacted": n_outputs + n_decision + n_error + n_actor + n_inputs, + }, clean_inputs + + +def emit(stage: str, step: str, status: str, *, trace_id: str | None = None, + duration_ms: int | None = None, inputs: list[dict] | None = None, outputs: dict | None = None, + decision: dict | None = None, error: dict | None = None, actor: dict | None = None, + source: str | None = None) -> int | None: + """写一个事件,返回事件 id;关闭、失败、被过滤掉整条事件时返回 None。永不抛异常。 + + trace_id 缺省 current_trace();source 缺省按环境取(真实 Actions 为 ci:::, + 其余 CI 为 "ci",本地为 "local");actor 缺省 {"role": "engine", "host": <来源的显示分类>}。 + step 先去首尾空白(含换行)再按字符串隐私规则过滤:非法整条不写,合法写清洗后的值。 + """ + try: + if not enabled(): + return None + step_text = str(step or "").strip() + if stage not in STAGES or status not in STATUSES or not step_text: + return None + if _clean_str(step_text, _MAX_STR) is None: + return None + source = source or default_source() + payload, clean_inputs = _build_payload(stage, step_text, status, source, + trace_id or current_trace(), duration_ms, + outputs, decision, error, + actor or {"role": "engine", "host": _host_class(source)}, inputs) + return events_db.insert_event(payload, clean_inputs) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + return None + + +class Span: + """span 上下文:块内可设置 status(默认 ok)、inputs、outputs、decision、error。""" + + _FIXED_KEYS = frozenset(("trace_id", "duration_ms", "inputs", "outputs", + "decision", "error", "actor", "source")) + + def __init__(self, stage: str, step: str, fixed: dict): + self.stage = stage + self.step = step + self.fixed = fixed + self.status = "ok" + self.inputs = None + self.outputs = None + self.decision = None + self.error = None + self._start = 0.0 + + +@contextmanager +def span(stage: str, step: str, **fixed) -> Iterator[Span]: + """计时包装:退出时 emit 一个事件(duration_ms 为块耗时)。 + + 块内抛异常:status 设为 "error"、error={"kind": 异常类型名},事件写入后重新抛出原异常。 + fixed 里 emit 认可的键(trace_id、actor、source 等)原样传递;未知键丢弃—— + 观察侧失败不得传播,也不得遮盖块内的业务异常。 + """ + holder = Span(stage, step, dict(fixed)) + holder._start = time.monotonic() + try: + yield holder + except BaseException as exc: + holder.status = "error" + holder.error = {"kind": type(exc).__name__} + raise + finally: + kwargs = { + "stage": holder.stage, "step": holder.step, "status": holder.status, + "duration_ms": int((time.monotonic() - holder._start) * 1000), + "inputs": holder.inputs, "outputs": holder.outputs, + "decision": holder.decision, "error": holder.error, + } + kwargs.update({key: value for key, value in holder.fixed.items() if key in Span._FIXED_KEYS}) + emit(**kwargs) + + +def set_anchor(trace_id: str, stage: str, head_hash: str, fixed_in: str, + source: str | None = None) -> None: + """在 anchors 表登记链头锚点(fixed_in:run_record / ci_artifact / pr_comment)。永不抛异常。""" + try: + if not enabled(): + return + events_db.add_anchor(source or default_source(), trace_id, stage, head_hash, fixed_in) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + + +def chain_head(trace_id: str, source: str | None = None) -> str | None: + """该 (source, trace) 最后一个事件的 hash(source 缺省同 emit);没有事件为 None。""" + try: + return events_db.chain_head(source or default_source(), trace_id) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return None + + +def verify_chain(trace_id: str | None = None, source: str | None = None) -> list[str]: + """校验哈希链,返回问题描述列表(空列表表示完好);trace_id、source 为 None 时校验全部。""" + try: + return events_db.verify(trace_id, source) + except Exception as exc: # noqa: BLE001 设计要求:链校验失败只报告,不影响调用方 + return [f"链校验失败:{exc}"] diff --git a/.harness/engine/core/events_db.py b/.harness/engine/core/events_db.py new file mode 100644 index 0000000..c81ac20 --- /dev/null +++ b/.harness/engine/core/events_db.py @@ -0,0 +1,333 @@ +"""事件存储层:SQLite 建库与迁移、带哈希链的事务写入、链校验、产物与锚点。 + +库位于 git 公共目录下的 harness/harness.db(所有 worktree 共用,位于 .git 内不会被跟踪),产物位于 +harness/artifacts/。本模块只负责存储与完整性,不做隐私过滤(调用方 engine/core/events.py 负责); +写入失败直接抛异常,由调用方吞掉。除本模块已知版本外不修改库结构:库的 user_version 较新时只读不写。 +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +import time +from contextlib import closing +from datetime import UTC, datetime +from itertools import count +from pathlib import Path + +from engine.core.common import ROOT, git + +SCHEMA_VERSION = 1 +BUSY_TIMEOUT_MS = 5000 +# events 表列序(INSERT 与链校验按此顺序取值)。 +EVENT_COLUMNS = ( + "id", "ts", "source", "trace_id", "seq", "prev_hash", "hash", "stage", "step", "status", + "duration_ms", "actor_role", "actor_host", "model", "decision_by", "decision_rule", + "decision_reason", "error_kind", "error_signature", "outputs", "engine_version", "redacted", +) +_REF_KEYS = ("kind", "ref", "sha256", "size") + +_CREATE_TABLES = """ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL, + source TEXT NOT NULL, + trace_id TEXT NOT NULL, + seq INTEGER NOT NULL, + prev_hash TEXT NOT NULL, + hash TEXT NOT NULL, + stage TEXT NOT NULL, + step TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER, + actor_role TEXT, + actor_host TEXT, + model TEXT, + decision_by TEXT, + decision_rule TEXT, + decision_reason TEXT, + error_kind TEXT, + error_signature TEXT, + outputs TEXT, + engine_version TEXT, + redacted INTEGER NOT NULL DEFAULT 0, + UNIQUE (source, trace_id, seq) +); +CREATE TABLE IF NOT EXISTS refs ( + event_id INTEGER NOT NULL, + direction TEXT NOT NULL, + kind TEXT, + ref TEXT, + sha256 TEXT, + size INTEGER +); +CREATE TABLE IF NOT EXISTS artifacts ( + sha256 TEXT PRIMARY KEY, + size INTEGER, + path TEXT, + created TEXT +); +CREATE TABLE IF NOT EXISTS anchors ( + id INTEGER PRIMARY KEY, + source TEXT, + trace_id TEXT, + stage TEXT, + head_hash TEXT, + fixed_in TEXT, + ts TEXT +); +""" + + +def _now() -> str: + """UTC 毫秒 ISO8601(带 Z 后缀),作为事件与锚点的时间戳。""" + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def common_dir() -> Path | None: + """git 公共目录(`.git`,worktree 指向主仓库);不在 git 仓库里返回 None。""" + out = git("rev-parse", "--git-common-dir", cwd=ROOT, check=False, isolate=True) + if not out: + return None + path = Path(out) + if not path.is_absolute(): + path = ROOT / path + return path.resolve() + + +def db_path() -> Path | None: + directory = common_dir() + return directory / "harness" / "harness.db" if directory else None + + +def artifacts_dir() -> Path | None: + directory = common_dir() + return directory / "harness" / "artifacts" if directory else None + + +def _connect(path: Path) -> sqlite3.Connection: + """每次操作新开连接(WAL、busy_timeout),用完由调用方 contextlib.closing 关闭。""" + conn = sqlite3.connect(path, timeout=BUSY_TIMEOUT_MS / 1000, isolation_level=None) + conn.execute(f"PRAGMA busy_timeout={BUSY_TIMEOUT_MS}") + return conn + + +def _switch_to_wal(conn: sqlite3.Connection) -> None: + """切到 WAL。多进程同时建库时该 PRAGMA 不受 busy_timeout 保护,需重试等待持有者完成。""" + for attempt in range(20): + try: + mode = conn.execute("PRAGMA journal_mode").fetchone()[0] + if str(mode).lower() == "wal": + return + conn.execute("PRAGMA journal_mode=WAL") + return + except sqlite3.OperationalError: + time.sleep(0.05 * (attempt + 1)) + raise sqlite3.OperationalError("切换 WAL 超时") + + +def _schema_ready(conn: sqlite3.Connection) -> bool: + """库版本可用返回 True;比本代码已知版本新时返回 False(不写入、不修改、不报错)。""" + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + return False + if version < SCHEMA_VERSION: + _switch_to_wal(conn) + conn.executescript(_CREATE_TABLES) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + return True + + +def _normalize_ref(item: dict) -> dict: + """引用只保留四定键中非 None 的值;写入与链校验共用,保证哈希两侧一致。""" + return {key: item[key] for key in _REF_KEYS if item.get(key) is not None} + + +def _hash(row: dict, inputs: list[dict]) -> str: + """规范化 JSON(除 id、hash 外的全部列加 inputs 列表,按键排序、紧凑分隔符)的 sha256。""" + payload = {key: value for key, value in row.items() if key not in ("id", "hash")} + payload["inputs"] = inputs + text = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def insert_event(payload: dict, inputs: list[dict]) -> int | None: + """写入一个事件并接上哈希链,返回事件 id。 + + seq 分配、prev_hash 读取、插入在同一个 BEGIN IMMEDIATE 事务里,多进程并发写同一 trace 链仍完好。 + 不在 git 仓库或库版本较新时返回 None(不算失败,不提示);其余失败抛异常,由调用方吞掉。 + """ + path = db_path() + if path is None: + return None + path.parent.mkdir(parents=True, exist_ok=True) + refs = [item for item in (_normalize_ref(entry) for entry in inputs) if item] # 过滤后的空引用不入表 + outputs_text = json.dumps(payload.get("outputs") or {}, sort_keys=True, + separators=(",", ":"), ensure_ascii=False) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return None + conn.execute("BEGIN IMMEDIATE") + try: + last = conn.execute( + "SELECT seq, hash FROM events WHERE source=? AND trace_id=? ORDER BY seq DESC LIMIT 1", + (payload["source"], payload["trace_id"]), + ).fetchone() + row = { + "ts": _now(), "source": payload["source"], "trace_id": payload["trace_id"], + "seq": 1 if last is None else last[0] + 1, + "prev_hash": "" if last is None else last[1], + "stage": payload["stage"], "step": payload["step"], "status": payload["status"], + "duration_ms": payload.get("duration_ms"), + "actor_role": payload.get("actor_role"), "actor_host": payload.get("actor_host"), + "model": payload.get("model"), + "decision_by": payload.get("decision_by"), "decision_rule": payload.get("decision_rule"), + "decision_reason": payload.get("decision_reason"), + "error_kind": payload.get("error_kind"), "error_signature": payload.get("error_signature"), + "outputs": outputs_text, "engine_version": payload.get("engine_version"), + "redacted": payload.get("redacted", 0), + } + row["hash"] = _hash(row, refs) + columns = [name for name in EVENT_COLUMNS if name != "id"] + cursor = conn.execute( + f"INSERT INTO events ({','.join(columns)}) VALUES ({','.join('?' * len(columns))})", + tuple(row[name] for name in columns), + ) + event_id = cursor.lastrowid + for item in refs: + conn.execute( + "INSERT INTO refs (event_id,direction,kind,ref,sha256,size) VALUES (?,?,?,?,?,?)", + (event_id, "in", item.get("kind"), item.get("ref"), item.get("sha256"), item.get("size")), + ) + conn.execute("COMMIT") + return event_id + except Exception: + try: + conn.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + + +def chain_head(source: str, trace_id: str) -> str | None: + """该 (source, trace) 最后一个事件的 hash;没有事件或没有库返回 None。""" + path = db_path() + if path is None or not path.exists(): + return None + with closing(_connect(path)) as conn: + row = conn.execute( + "SELECT hash FROM events WHERE source=? AND trace_id=? ORDER BY seq DESC LIMIT 1", + (source, trace_id), + ).fetchone() + return row[0] if row else None + + +def verify(trace_id: str | None = None, source: str | None = None) -> list[str]: + """校验哈希链,返回问题描述列表(空列表表示完好);trace_id、source 为 None 时校验全部。 + + 先看库版本:schema 比本代码新时明确报告不可校验,不修改库、不当作完好。 + """ + path = db_path() + if path is None or not path.exists(): + return [] + problems: list[str] = [] + with closing(_connect(path)) as conn: + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + return [f"harness.db 的 schema 版本较新(user_version={version} > {SCHEMA_VERSION}),不可校验"] + where, params = [], [] + if trace_id is not None: + where.append("trace_id=?") + params.append(trace_id) + if source is not None: + where.append("source=?") + params.append(source) + clause = f" WHERE {' AND '.join(where)}" if where else "" + groups = conn.execute("SELECT DISTINCT source, trace_id FROM events" + clause, params).fetchall() + columns = ",".join(EVENT_COLUMNS) + for src, trace in groups: + rows = conn.execute( + f"SELECT {columns} FROM events WHERE source=? AND trace_id=? ORDER BY seq", + (src, trace), + ).fetchall() + problems.extend(_verify_group(conn, src, trace, rows)) + return problems + + +def _verify_group(conn: sqlite3.Connection, source: str, trace_id: str, + rows: list[tuple]) -> list[str]: + """校验一条 (source, trace) 链:seq 连续、prev_hash 相接、内容与 hash 一致。""" + problems = [] + expected_seq = 1 + prev_hash = "" + for values in rows: + row = dict(zip(EVENT_COLUMNS, values)) + refs = conn.execute( + "SELECT kind,ref,sha256,size FROM refs WHERE event_id=? ORDER BY rowid", (row["id"],) + ).fetchall() + inputs = [_normalize_ref(dict(zip(_REF_KEYS, ref_row))) for ref_row in refs] + if row["seq"] != expected_seq: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的 seq 应为 {expected_seq},实际 {row['seq']}") + if row["prev_hash"] != prev_hash: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的 prev_hash 与前一事件 hash 不符") + if _hash(row, inputs) != row["hash"]: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的内容与 hash 不符") + expected_seq = row["seq"] + 1 + prev_hash = row["hash"] + return problems + + +_TMP_SEQ = count() + + +def _write_artifact_file(target: Path, content: bytes) -> None: + """产物落盘:先写临时文件再原子替换;失败不留下半文件与临时残留,已有内容不重复写。 + + 临时文件名带进程号与计数,多进程/多线程并发存同一产物互不冲突;os.replace 原子生效, + 内容寻址保证同名即同内容,先后替换结果一致。 + """ + if target.exists(): + return + tmp = target.with_name(f"{target.name}.tmp-{os.getpid()}-{next(_TMP_SEQ)}") + try: + with open(tmp, "wb") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) # 替换成功后临时文件已不存在;失败时清掉残留 + + +def save_artifact(digest: str, size: int, content: bytes) -> None: + """内容寻址保存产物(临时文件写完后原子替换,已存在则不重复写)并登记 artifacts 表。""" + directory = artifacts_dir() + if directory is None: + return + directory.mkdir(parents=True, exist_ok=True) + target = directory / digest + _write_artifact_file(target, content) + path = db_path() + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return + conn.execute("INSERT OR IGNORE INTO artifacts (sha256,size,path,created) VALUES (?,?,?,?)", + (digest, size, target.as_posix(), _now())) + + +def add_anchor(source: str, trace_id: str, stage: str, head_hash: str, fixed_in: str) -> None: + """在 anchors 表登记一条链头锚点(fixed_in:run_record / ci_artifact / pr_comment)。""" + path = db_path() + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return + conn.execute("INSERT INTO anchors (source,trace_id,stage,head_hash,fixed_in,ts) VALUES (?,?,?,?,?,?)", + (source, trace_id, stage, head_hash, fixed_in, _now())) diff --git a/.harness/engine/guards/command_guard.py b/.harness/engine/guards/command_guard.py index e263bb9..a0757f9 100644 --- a/.harness/engine/guards/command_guard.py +++ b/.harness/engine/guards/command_guard.py @@ -8,6 +8,8 @@ --format json {"command": "..."}、{"file_path": "..."} 或 {"tool_name": "..."}(OpenCode 插件用) --format plain 整段 stdin 就是命令 拒绝时退出码 2,理由写 stderr(Claude Code 会把它反馈给模型);放行退出码 0。 +拒绝在本报告边界逐条写 guard 事件(规则键、角色、工具类别、安全相对目标;理由与命令全文不进事件), +放行不记也不计数(设计 3.6,执行环节的放行数由 T105 从工具完成事件汇总)。 --role implementer 另外禁止编辑判定器与护栏(CI、hooks、harness、Agent 配置):执行者改不了判定器; 也不能编辑合同(任务书与现役规格)与运行记录(docs/runs/),不能关闭、改动议题与标签。 @@ -22,61 +24,74 @@ import sys from pathlib import Path -from engine.core import shell_structure +from engine.core import events, shell_structure from engine.core.common import path_matches, setting +from engine.guards import shell_structure as guard_structure -# (模式, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 -COMMAND_RULES: list[tuple[str, str]] = [ +# (模式, 规则键, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 +# 规则键是拒绝事件里的稳定标识(设计 3.6):理由文案可能调整且可能含命令字样,事件只记键、不复制理由。 +COMMAND_RULES: list[tuple[str, str, str]] = [ ( r"\bgit\s+filter-(repo|branch)\b|(^|[;&|(]|&&)\s*(\S*/)?git-filter-repo\b", + "history_rewrite", "改写历史(v0.8.0 曾因此改写 main 与 18 个 tag)", ), # 结尾用「后面不是词字符」而非空白:解释器代码里常紧跟引号或括号(os.system('git push --force'))。 - (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "强制推送:已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s\+\S+", "强制推送(+refspec):已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "批量推送(--mirror/--all/--tags)"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "直接推送 main:请推功能分支并开 PR"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "推送 tag:推 tag 会触发发布,只能由用户执行"), - (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "创建、移动或删除 tag:发版由用户执行"), - (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "直接改写 main 或 tag 引用"), - (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "--no-verify 跳过 git 守卫"), - (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "-n(--no-verify)跳过 git 守卫"), + (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "force_push", + "强制推送:已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s\+\S+", "force_push_refspec", "强制推送(+refspec):已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "batch_push", "批量推送(--mirror/--all/--tags)"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "push_main", "直接推送 main:请推功能分支并开 PR"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "push_tag", + "推送 tag:推 tag 会触发发布,只能由用户执行"), + (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "tag_write", + "创建、移动或删除 tag:发版由用户执行"), + (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "update_ref_protected", "直接改写 main 或 tag 引用"), + (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "no_verify", "--no-verify 跳过 git 守卫"), + (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "no_verify_short", "-n(--no-verify)跳过 git 守卫"), ( # 只拦设置或取消;`git config --get` 等只读查询放行(此前任何提及都拦,误伤自检)。 ( r"(?i)-c\s*core\.hookspath=|\bgit\s+config\b(?![^;&|]*--(get|get-all|get-regexp|list|show-origin)\b)" r"[^;&|]*core\.hookspath" ), + "hookspath_write", "修改 core.hooksPath 会绕过 git 守卫", ), - # 覆盖变量:出现变量名或其后半截即拒(拼接如 ${P}_ALLOW_TAG 也能命中;评审 PR7-R6)。 - (r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b", "覆盖变量只供人使用,Agent 不能自行放开守卫"), - (r"\bgit\s+reset\s+[^;&|]*--hard\b", "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), + # 覆盖变量(override_var)不在此表:按 shell 结构只在真赋值位置判定,见 engine/guards/shell_structure(B60), + # 避免 heredoc 正文与引号内的文字被误当赋值。 + (r"\bgit\s+reset\s+[^;&|]*--hard\b", "reset_hard", + "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), # 解析失败时的兜底(宁可误报):-x 且没有任何 -e 排除即拒;能解析时由 shell_structure 按 [runtime] preserve 精确判断。 - (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), + (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "clean_keep", + "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), ( r"\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\b[^;&|]*?\s(/(?!tmp/|private/tmp/|var/folders/)|~|\$HOME|\.\.)", + "rm_outside", "递归删除工作区以外的路径(临时目录 /tmp 除外)", ), # 只读的 list、view 放行(审计 G11);创建、编辑、上传、删除发布由用户执行。 - (r"\bgh\s+release\b(?!\s+(list|view)\b)", shell_structure.RELEASE), + (r"\bgh\s+release\b(?!\s+(list|view)\b)", "release_write", shell_structure.RELEASE), # 议题与标签是逃逸、抽审、升级的登记(设计 14.3):登记只会让系统更保守,删除或撤标签会让误差预算失真。 - (r"\bgh\s+issue\s+delete\b", shell_structure.ISSUE_DELETE), + (r"\bgh\s+issue\s+delete\b", "issue_delete", shell_structure.ISSUE_DELETE), ( r"\bgh\s+(issue|pr)\s+edit\b[^;&|]*--remove-label[\s=][^;&|]*\b(escape|audit|escalation|budget-exceeded)\b", + "label_erase", shell_structure.LABEL_ERASE, ), ( r"\bgh\s+label\s+(delete|edit)\b[^;&|]*\b(escape|audit|escalation|budget-exceeded|class:K\d)\b", + "label_erase", shell_structure.LABEL_ERASE, ), - (r"\bgh\s+(run|repo)\s+delete\b", "删除 CI 记录或远端资源会销毁证据"), + (r"\bgh\s+(run|repo)\s+delete\b", "delete_remote", "删除 CI 记录或远端资源会销毁证据"), ( # gh api / curl 的写请求可绕过分支保护:PATCH 改远端 ref 即服务端强推,PUT contents 直写 main(评审 PR7-R6)。 ( r"(?i)\bgh\s+api\b[^;&|]*\s(-X\s*|--method[\s=]+)(POST|PUT|PATCH|DELETE)\b" r"|\bgh\s+api\b[^;&|]*\s(-f|-F|--field|--raw-field|--input)(\s|=)" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ( @@ -87,32 +102,35 @@ r"(?=[^;&|]*\s(-X\s*|--request[\s=]+)(?i:POST|PUT|PATCH|DELETE)\b" r"|[^;&|]*\s(-[dFT]\S*|--data\S*|--form\S*|--upload-file|--json)(\s|=|$))" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ] # 合并 PR 不归 Agent(用户 2026-09-25 决定 D4):R0/R1 由仓库 auto-merge 在门禁全绿后合并,R2 以上由用户合并。 MERGE_REASON = "合并 PR:R0/R1 由仓库 auto-merge 合并,R2 及以上由用户合并,Agent 不自行合并(方案 §13 D4)" -COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", MERGE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", "merge_pr", MERGE_REASON)) # 批准也不归 Agent(D3):ruleset 要求非推送者批准最后一次推送,Agent 批准就等于替用户放行。 APPROVE_REASON = "批准 PR:合并前须由非推送者批准;R0/R1 由 auto-merge 的 App 批准,R2 及以上由用户批准,Agent 不批准(方案 §13 D3)" -COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", APPROVE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", "approve_pr", APPROVE_REASON)) # 执行者另外不能关闭、重开、改动议题(含改标签):逃逸与抽审由设计评审方处理(设计 14.3)。 ISSUE_REASON = shell_structure.ISSUE_IMPLEMENTER -IMPLEMENTER_COMMAND_RULES: list[tuple[str, str]] = [ - (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", ISSUE_REASON), - (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", ISSUE_REASON), - (r"\bgh\s+label\b(?!\s+list\b)", ISSUE_REASON), +IMPLEMENTER_COMMAND_RULES: list[tuple[str, str, str]] = [ + (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+label\b(?!\s+list\b)", "issue_write", ISSUE_REASON), ] # 按工具名拒绝的 MCP 等非命令工具(如 GitHub MCP 的 merge_pull_request、enable_pr_auto_merge)。 -TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", shell_structure.LABEL_ERASE), - (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", MERGE_REASON), +TOOL_RULES: list[tuple[str, str, str]] = [ + (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", "tool_label_erase", + shell_structure.LABEL_ERASE), + (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", "tool_merge_pr", MERGE_REASON), # MCP 的评审工具按名字看不出是批准还是评论,一律拒绝;Agent 的评审意见写进报告或用 gh pr comment。 ( ( r"(?i)(^|[_.])(pull_request_review_write|create_pull_request_review|create_and_submit_pull_request_review" r"|submit_pending_pull_request_review|approve_pull_request|approve_pr)$" ), + "tool_approve_pr", APPROVE_REASON, ), ] @@ -141,19 +159,68 @@ def protected_for_implementer() -> list[str]: CONTRACTS_FOR_IMPLEMENTER = ["docs/plans/task-*.md", "docs/specs/**"] # 运行记录由派发脚本生成,是度量与审计的输入(设计 10.1),执行方不能编辑。 RUNS_FOR_IMPLEMENTER = ["docs/runs/**"] -IMPLEMENTER_TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", ISSUE_REASON), +IMPLEMENTER_TOOL_RULES: list[tuple[str, str, str]] = [ + ( + r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", + "tool_issue_write", + ISSUE_REASON, + ), ] -_COMPILED = [(re.compile(pattern), reason) for pattern, reason in COMMAND_RULES] -_TOOLS = [(re.compile(pattern), reason) for pattern, reason in TOOL_RULES] -_IMPLEMENTER_COMMANDS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_COMMAND_RULES] -_IMPLEMENTER_TOOLS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_TOOL_RULES] +_COMPILED = [(re.compile(pattern), key, reason) for pattern, key, reason in COMMAND_RULES] +_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in TOOL_RULES] +_IMPLEMENTER_COMMANDS = [(re.compile(pattern), key, reason) + for pattern, key, reason in IMPLEMENTER_COMMAND_RULES] +_IMPLEMENTER_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in IMPLEMENTER_TOOL_RULES] + +# 结构化规则(shell_structure)给出的理由 → 规则键;随后命令字符串规则表覆盖同名理由(同类拒绝共用一键)。 +_COMMAND_REASON_KEYS: dict[str, str] = { + shell_structure.FILTER: "history_rewrite", + shell_structure.FORCE: "force_push", + shell_structure.FORCE_REFSPEC: "force_push_refspec", + shell_structure.BATCH: "batch_push", + shell_structure.PUSH_MAIN: "push_main", + shell_structure.PUSH_TAG: "push_tag", + shell_structure.TAG: "tag_write", + shell_structure.UPDATE_REF: "update_ref_protected", + shell_structure.NO_VERIFY: "no_verify", + shell_structure.NO_VERIFY_SHORT: "no_verify_short", + shell_structure.HOOKS_PATH: "hookspath_write", + shell_structure.OVERRIDE: "override_var", + shell_structure.RESET_HARD: "reset_hard", + shell_structure.RM_OUTSIDE: "rm_outside", + shell_structure.RELEASE: "release_write", + shell_structure.ISSUE_DELETE: "issue_delete", + shell_structure.LABEL_ERASE: "label_erase", + shell_structure.ISSUE_IMPLEMENTER: "issue_write", + shell_structure.DELETE_REMOTE: "delete_remote", + shell_structure.API_WRITE: "api_write", + shell_structure.MERGE: "merge_pr", + shell_structure.APPROVE: "approve_pr", +} +for _rules in (COMMAND_RULES, IMPLEMENTER_COMMAND_RULES): + for _pattern, _key, _reason in _rules: + _COMMAND_REASON_KEYS[_reason] = _key +_TOOL_REASON_KEYS = {reason: key for _pattern, key, reason in TOOL_RULES + IMPLEMENTER_TOOL_RULES} +# 带填充值的理由(clean_keep 的保留路径清单)按前缀取键。 +_REASON_KEY_PREFIXES = [(shell_structure.CLEAN_X.split("{paths}")[0], "clean_keep")] + + +def rule_key(reason: str, category: str = "command") -> str: + """拒绝理由 → 稳定规则键。理由文本可能含命令、路径或会话正文,永远不进事件; + 未登记的理由(如将来新增规则暂未配键)统一记 guard_denial,只影响粒度、不影响拒绝本身。""" + table = _TOOL_REASON_KEYS if category == "tool" else _COMMAND_REASON_KEYS + if reason in table: + return table[reason] + for prefix, key in _REASON_KEY_PREFIXES: + if reason.startswith(prefix): + return key + return "guard_denial" def check_tool(name: str, role: str = "designer") -> list[str]: rules = _TOOLS + (_IMPLEMENTER_TOOLS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(name)] + return [reason for pattern, _key, reason in rules if pattern.search(name)] def check_command(command: str, role: str = "designer") -> list[str]: @@ -162,17 +229,21 @@ def check_command(command: str, role: str = "designer") -> list[str]: reasons = shell_structure.check(command, lambda text: check_command_text(text, role), role) except ValueError: # 引号不配对等无法解析:退回字符串规则(宁可误报) reasons = check_command_text(command, role) + # 覆盖变量单独按结构判定(B60):heredoc 正文与引号内文字是数据,只在真赋值位置拒绝。 + reasons += guard_structure.check(command) return list(dict.fromkeys(reasons)) def check_command_text(command: str, role: str = "designer") -> list[str]: """字符串规则:整段文本里出现危险字样即拒。只用于会执行代码的文本(解释器代码、交给 shell 的 stdin) - 与无法解析的命令。""" + 与无法解析的命令。覆盖变量不在此列:它按 shell 结构判定(guard_structure.check), + 否则解释器代码与 stdin 正文里的文字会被误当赋值(B60)。""" normalized = " " + re.sub(r"\s+", " ", command.strip()) + " " # 再检查一遍去掉引号与反斜杠的形式:`"HARNESS"'_ALLOW_TAG'`、`HAR\\NESS_...` 这类拆写(PR7-R6)。 unquoted = re.sub(r"[\"'\\]", "", normalized) rules = _COMPILED + (_IMPLEMENTER_COMMANDS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(normalized) or pattern.search(unquoted)] + return [reason for pattern, _key, reason in rules + if pattern.search(normalized) or pattern.search(unquoted)] def _relative(path: str, root: Path) -> str: @@ -185,22 +256,66 @@ def _relative(path: str, root: Path) -> str: return candidate.as_posix().removeprefix("./") -def check_edit(path: str, role: str, root: Path) -> list[str]: +def _edit_denials(path: str, role: str, root: Path) -> list[tuple[str, str]]: + """编辑拒绝的 (规则键, 理由)。理由含目标路径,事件里只取键与安全目标(见 _safe_target)。""" if role != "implementer": return [] relative = _relative(path, root) pattern = path_matches(relative, protected_for_implementer()) if pattern and relative not in IMPLEMENTER_EDITABLE: - return [f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方"] + return [("edit_protected", f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方")] pattern = path_matches(relative, CONTRACTS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包"] + return [("edit_contract", f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包")] pattern = path_matches(relative, RUNS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成"] + return [("edit_runs", f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成")] return [] +def check_edit(path: str, role: str, root: Path) -> list[str]: + return [reason for _key, reason in _edit_denials(path, role, root)] + + +def _safe_target(path: str, root: Path) -> str | None: + """编辑拒绝的仓库相对目标:只在能安全归化为仓库内相对路径时返回,否则省略(None)。 + + 不把本机绝对路径清洗成貌似可信的相对路径:根外绝对路径、逃出仓库的相对路径一律省略。 + """ + candidate = Path(path) + try: + if candidate.is_absolute(): + return candidate.resolve().relative_to(root.resolve()).as_posix() + relative = candidate.as_posix().removeprefix("./") + except (ValueError, OSError): + return None + parts = Path(relative).parts + if not relative or parts in ((".",), ("/",)) or ".." in parts: + return None + return relative + + +def denial_entries(payload: dict, role: str = "designer", + root: Path | None = None) -> list[tuple[str, str, str, str | None]]: + """报告边界的拒绝条目 (理由, 类别, 规则键, 安全相对目标):evaluate 的理由序列即本表的理由投影。 + + 类别为 tool / command / edit;目标只对编辑拒绝从载荷的文件路径安全提取,命令拒绝不解析命令文本、 + 一律省略。理由可能含命令、路径或会话正文,调用方不得把它写进事件。 + """ + root = root or Path.cwd() + command, path = extract(payload) + entries: list[tuple[str, str, str, str | None]] = [] + tool = payload.get("tool_name") + if isinstance(tool, str): + entries += [(reason, "tool", rule_key(reason, "tool"), None) for reason in check_tool(tool, role)] + if command: + entries += [(reason, "command", rule_key(reason), None) for reason in check_command(command, role)] + if path: + target = _safe_target(path, root) + entries += [(reason, "edit", key, target) for key, reason in _edit_denials(path, role, root)] + return entries + + def extract(payload: dict) -> tuple[str | None, str | None]: """从各家载荷中取出 (命令, 文件路径)。""" tool_input = payload.get("tool_input", payload) @@ -214,17 +329,26 @@ def extract(payload: dict) -> tuple[str | None, str | None]: def evaluate(payload: dict, role: str = "designer", root: Path | None = None) -> list[str]: - root = root or Path.cwd() - command, path = extract(payload) - reasons = [] - tool = payload.get("tool_name") - if isinstance(tool, str): - reasons += check_tool(tool, role) - if command: - reasons += check_command(command, role) - if path: - reasons += check_edit(path, role, root) - return reasons + """拒绝理由列表:按工具、命令、编辑顺序聚合,按理由去重(口径与埋点前的判定一致)。""" + return list(dict.fromkeys(entry[0] for entry in denial_entries(payload, role, root))) + + +def _emit_denials(entries: list[tuple[str, str, str, str | None]], role: str) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有规则键、角色、类别与安全相对目标。 + + 理由与命令全文不进事件;emit 失败自行吞掉并提示一次,退出码与 stderr 的拒绝文本不受影响。 + """ + seen: set[tuple[str, str, str | None]] = set() + for _reason, category, key, target in entries: + identity = (category, key, target) + if identity in seen: + continue + seen.add(identity) + outputs = {"category": category} + if target: + outputs["target"] = target + events.emit("guard", "command", "deny", decision={"by": "guard", "rule": key}, + outputs=outputs, actor={"role": role}) def main(argv: list[str] | None = None) -> int: @@ -241,13 +365,14 @@ def main(argv: list[str] | None = None) -> int: except json.JSONDecodeError: print("harness 守卫:无法解析工具调用载荷,按拒绝处理。", file=sys.stderr) return 2 - reasons = evaluate(payload, args.role) - if not reasons: + entries = denial_entries(payload, args.role) + if not entries: return 0 print("harness 守卫拒绝了这次操作:", file=sys.stderr) - for reason in dict.fromkeys(reasons): + for reason in dict.fromkeys(entry[0] for entry in entries): print(f" - {reason}", file=sys.stderr) print(" 如确需执行,请把原因和命令交给用户决定(规则见 .harness/config/rules.toml)。", file=sys.stderr) + _emit_denials(entries, args.role) return 2 diff --git a/.harness/engine/guards/git_guard.py b/.harness/engine/guards/git_guard.py index 63619a8..5aeec42 100644 --- a/.harness/engine/guards/git_guard.py +++ b/.harness/engine/guards/git_guard.py @@ -9,6 +9,8 @@ 对应 v0.8.0 X3:filter-repo 改写了本地 main 与 18 个 tag,并删除了 origin。 覆盖变量见 .harness/config/rules.toml [guard];只供人使用。 +三个钩子的拒绝在报告边界逐条写 guard 事件(钩子名、分支、规则键;理由文本含 ref 与规则说明,不进事件), +放行不记(设计 3.6)。 """ from __future__ import annotations @@ -20,6 +22,7 @@ from pathlib import Path from engine.checks import hygiene +from engine.core import events from engine.core.common import LEGACY_RULES_REL, ROOT, RULES_REL, ZERO_SHA, git, load_rules HOOKS_DIR = ".githooks" @@ -99,11 +102,11 @@ def _packing_loose_ref(ref: str, old: str, cwd: Path) -> bool: return _transaction_subcommand() == "pack-refs" -def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - """reference-transaction 的 prepared 阶段:返回应拒绝的更新。""" +def ref_transaction_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """reference-transaction 的 prepared 阶段:返回应拒绝的 (规则键, 理由, 引用)。""" if _allowed("HARNESS_ALLOW_REWRITE"): return [] - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 3: @@ -116,17 +119,18 @@ def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list continue # 散文件并入 packed-refs,引用本身不变 if ref.startswith("refs/tags/") and old != ZERO_SHA and old != new: action = "删除" if new == ZERO_SHA else "移动" - problems.append(f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)") + denials.append(("tag_rewrite", f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)", ref)) elif _protected(ref, rules) and old != ZERO_SHA and old != new: if new == ZERO_SHA: - problems.append(f"删除保护分支 {ref}") + denials.append(("protected_rewrite", f"删除保护分支 {ref}", ref)) elif not _is_ancestor(old, new, cwd): - problems.append(f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)") - return problems + denials.append(("protected_rewrite", f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)", ref)) + return denials -def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - problems = [] +def pre_push_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """pre-push 的引用检查:返回应拒绝的 (规则键, 理由, 远端引用)。""" + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4: @@ -134,18 +138,22 @@ def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: _local_ref, local_sha, remote_ref, remote_sha = parts if remote_ref.startswith("refs/tags/"): if not _allowed("HARNESS_ALLOW_TAG"): - problems.append(f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行") + denials.append(("push_tag", f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行", remote_ref)) continue if _protected(remote_ref, rules) and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR") + denials.append(("push_protected_branch", f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR", remote_ref)) continue if local_sha == ZERO_SHA or remote_sha == ZERO_SHA or _allowed("HARNESS_ALLOW_REWRITE"): continue # 删除分支、新建分支 if not _exists(remote_sha, cwd): - problems.append(f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交") + denials.append(("push_stale_remote", + f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交", + remote_ref)) elif not _is_ancestor(remote_sha, local_sha, cwd): - problems.append(f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写") - return problems + denials.append(("force_push", + f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写", + remote_ref)) + return denials def current_branch(cwd: Path) -> str: @@ -175,38 +183,46 @@ def _run_verify(repo: Path, *args: str) -> int: def cmd_pre_commit(repo: Path) -> int: rules = trusted_rules(repo) - problems = [] + denials: list[tuple[str, str, str]] = [] branch = current_branch(repo) if branch in rules["guard"]["protected_branches"] and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"在保护分支 {branch} 上提交:请在功能分支上工作") - problems += [violation.render() for violation in hygiene.scan_staged(repo, rules)] - if _report("提交", problems): + denials.append(("commit_protected_branch", f"在保护分支 {branch} 上提交:请在功能分支上工作", branch)) + denials += [("staged_hygiene", violation.render(), branch) for violation in hygiene.scan_staged(repo, rules)] + code = _report("提交", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-commit", denials) return 1 return _run_verify(repo, "--quick") -def push_hygiene_violations(lines: list[str], repo: Path, rules: dict) -> list[str]: +def push_hygiene_denials(lines: list[str], repo: Path, rules: dict) -> list[tuple[str, str, str]]: """本次推送带出的改动做卫生检查(与 CI 的 --range 同口径,提前在本机发现)。""" - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4 or parts[1] == ZERO_SHA or parts[2].startswith("refs/tags/"): continue - local_sha, remote_sha = parts[1], parts[3] + local_sha, remote_ref, remote_sha = parts[1], parts[2], parts[3] if remote_sha != ZERO_SHA and _exists(remote_sha, repo): base = remote_sha else: base = git("merge-base", local_sha, "origin/main", cwd=repo, check=False) if not base: continue - problems += [violation.render() for violation in hygiene.scan_range(base, local_sha, repo, rules)] - return problems + denials += [("push_hygiene", violation.render(), remote_ref) + for violation in hygiene.scan_range(base, local_sha, repo, rules)] + return denials def cmd_pre_push(repo: Path, stdin: str) -> int: rules = trusted_rules(repo) lines = stdin.splitlines() - if _report("推送", pre_push_violations(lines, repo, rules) or push_hygiene_violations(lines, repo, rules)): + denials = pre_push_denials(lines, repo, rules) + if not denials: + denials = push_hygiene_denials(lines, repo, rules) + code = _report("推送", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-push", denials) return 1 return _run_verify(repo) @@ -214,7 +230,19 @@ def cmd_pre_push(repo: Path, stdin: str) -> int: def cmd_reference_transaction(repo: Path, state: str, stdin: str) -> int: if state != "prepared": return 0 - return _report("引用更新", ref_transaction_violations(stdin.splitlines(), repo, trusted_rules(repo))) + denials = ref_transaction_denials(stdin.splitlines(), repo, trusted_rules(repo)) + code = _report("引用更新", [denial[1] for denial in denials]) + if code: + _emit_denials("reference-transaction", denials) + return code + + +def _emit_denials(hook: str, denials: list[tuple[str, str, str]]) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有钩子名、分支与规则键,理由文本不进事件; + 事件写入失败由 emit 吞掉并提示一次,钩子的拒绝输出与退出码不变。""" + for key, _message, branch in denials: + events.emit("guard", "git", "deny", decision={"by": "guard", "rule": key}, + outputs={"hook": hook, "branch": branch}) def hooks_path(cwd: Path = ROOT) -> str: diff --git a/.harness/engine/guards/shell_structure.py b/.harness/engine/guards/shell_structure.py new file mode 100644 index 0000000..4b664d3 --- /dev/null +++ b/.harness/engine/guards/shell_structure.py @@ -0,0 +1,29 @@ +"""覆盖变量判定的 shell 结构解析(B60):引号内文字与 heredoc 正文一律当数据。 + +覆盖变量(HARNESS_*、*_ALLOW_MAIN/TAG/REWRITE、_SKIP_VERIFY 及拼接形式如 ${P}_ALLOW_TAG) +只供人使用,Agent 不能自行设置;但只看真正的赋值位置:命令前缀赋值(VAR=… cmd)、 +`env VAR=…`、`export VAR=…`,以及递归展开后同样的位置(`bash -c`、`eval`、`$(...)`、反引号)。 +heredoc(`<<`、`<<-`)与 here-string(`<<<`)正文、引号内文字、普通参数(如 `grep HARNESS_ …`) +一律当数据,不参与覆盖变量判定。 + +结构展开委托给 engine.core.shell_structure(把命令拆成简单命令并递归处理嵌套结构), +这里只取其中覆盖变量的判定结果;无法解析(引号不配对)时保守地整段扫描原文(宁可误报)。 +""" + +from __future__ import annotations + +import re + +from engine.core import shell_structure as structure + +# 出现变量名或其后半截即视为覆盖变量(拼接如 ${P}_ALLOW_TAG 也能命中;评审 PR7-R6)。 +PATTERN = re.compile(r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b") + + +def check(command: str) -> list[str]: + """命令在真赋值位置设置覆盖变量时返回拒绝理由;数据里的文字(heredoc 正文、引号内)不拒绝。""" + try: + found = structure.check(command, lambda _text: [], "designer") + except ValueError: # 引号不配对等无法解析:退回整段扫描(宁可误报) + return [structure.OVERRIDE] if PATTERN.search(command) else [] + return [reason for reason in found if reason == structure.OVERRIDE] diff --git a/.harness/engine/reports/metrics.py b/.harness/engine/reports/metrics.py index cf0e9a3..37c3c18 100644 --- a/.harness/engine/reports/metrics.py +++ b/.harness/engine/reports/metrics.py @@ -2,7 +2,7 @@ bin/harness metrics --base origin/main [--head HEAD] [--github] [--json] ---github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 build workflow 运行(CI 轮次、失败轮次); +--github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 CI 工作流运行(rules.toml [dispatch] ci_workflows)(CI 轮次、失败轮次); 没有凭据时标为「不可用」,不猜。评审轮次、自述不实、人工介入由人记录在交付说明中(见 docs/plans/verifiable-delivery.md 的试跑规程)。 """ @@ -17,8 +17,9 @@ from pathlib import Path from engine.checks import evidence +from engine.core import events from engine.core.cases import load_project_cases -from engine.core.common import ROOT, added_lines, git, setting +from engine.core.common import ROOT, added_lines, ci_workflows, git, setting from engine.routing import risk, run_check @@ -50,14 +51,15 @@ def github_runs(branch: str) -> dict | None: token, repo = os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_REPOSITORY") if not token or not repo: return None - query = urllib.parse.urlencode({"branch": branch, "per_page": 100}) + query = urllib.parse.urlencode({"branch": branch, "event": "pull_request", "per_page": 100}) request = urllib.request.Request( - f"https://api.github.com/repos/{repo}/actions/workflows/build.yml/runs?{query}", + f"https://api.github.com/repos/{repo}/actions/runs?{query}", headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, ) try: with urllib.request.urlopen(request, timeout=20) as response: - runs = json.load(response).get("workflow_runs", []) + names = set(ci_workflows()) + runs = [run for run in json.load(response).get("workflow_runs", []) if run.get("name") in names] except (OSError, ValueError): return None return { @@ -97,6 +99,24 @@ def collect(base: str, head: str = "HEAD", use_github: bool = False, cwd: Path = return data +def _record(data: dict, base: str, head: str) -> None: + """观察旁路:每个数值度量一条事件;列表度量拆成计数与逐条目,避免嵌套结构被过滤丢弃。""" + try: + inputs = [events.ref("rev", base), events.ref("rev", head)] + for key, value in data.items(): + if isinstance(value, list): + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "count": len(value)}) + for item in value: + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "item": str(item)}) + else: + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "value": value}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def render(data: dict) -> str: lines = [f"### 交付度量(`{data['base']}` → `{data['head']}`)", "", "| 指标 | 本次 |", "|---|---|"] for key, value in data.items(): @@ -117,7 +137,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--json", action="store_true") parser.add_argument("--markdown", help="追加写入该文件(CI 写 job summary)") args = parser.parse_args(argv) - data = collect(args.base, args.head, args.github) + data = collect(args.base, args.head, args.github, cwd=ROOT) + _record(data, args.base, args.head) # 观察:全部数值度量逐项 text = json.dumps(data, ensure_ascii=False, indent=2) if args.json else render(data) print(text) if args.markdown: diff --git a/.harness/engine/reports/weekly.py b/.harness/engine/reports/weekly.py index 6614150..bae1e43 100644 --- a/.harness/engine/reports/weekly.py +++ b/.harness/engine/reports/weekly.py @@ -22,7 +22,7 @@ from pathlib import Path from engine.checks import r1_checks -from engine.core.common import ROOT, git, setting +from engine.core.common import ROOT, ci_workflows, git, setting from engine.reports import metrics from engine.routing import policy, risk @@ -120,8 +120,10 @@ def issues(label: str) -> list[dict]: for pr in week.prs: week.modified_tests += _modifies_tests(pr, cwd) if pr["headRefName"] in dispatched: - builds = json.loads(gh("run", "list", "--workflow", "build", "--branch", pr["headRefName"], "--limit", "100", - "--json", "headSha,status,event")) + builds = [] + for workflow in ci_workflows(): + builds += json.loads(gh("run", "list", "--workflow", workflow, "--branch", pr["headRefName"], + "--limit", "100", "--json", "headSha,status,event")) week.ci_rounds[pr["number"]] = len({run["headSha"] for run in builds if run["status"] == "completed" and run["event"] == "pull_request"}) return week diff --git a/.harness/engine/routing/policy.py b/.harness/engine/routing/policy.py index cc64e3c..5b44ac9 100644 --- a/.harness/engine/routing/policy.py +++ b/.harness/engine/routing/policy.py @@ -9,8 +9,8 @@ 3. 预算 该类最近 window 次合并中的 escape 议题不超过 max_escapes;PR 不带 budget-exceeded 标签 4. 规模 增删行数(不计黄金快照与运行记录)不超过 autonomy.toml [size] max_lines 5. 运行记录 实现某份任务书的 PR(run_check.py):提交都带 `Task:`、有格式完整且 exit 为 ok 的运行记录、 - 已完成的 build 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 -读不到标签、议题或 build 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → + 已完成的 CI 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 +读不到标签、议题或 CI 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → Disable workflow(docs/specs/delivery-harness.md §8)。 K3 按 PR 编号哈希每 audit_every 个抽 1 个,合并后由工作流开 audit 议题。 @@ -30,7 +30,8 @@ from pathlib import Path from engine.checks import r1_checks, taskbook -from engine.core.common import ROOT, ConfigError, commit_field, git, path_matches, setting +from engine.core import events +from engine.core.common import ROOT, RULES_PATH, ConfigError, ci_workflows, commit_field, git, path_matches, setting from engine.routing import risk, run_check CONTRACT_PATTERNS = ["docs/plans/task-*.md", "docs/templates/**", "docs/plans/backlog.md", "docs/specs/**"] @@ -208,18 +209,21 @@ def escapes_in_window(klass: str, window: int, gh=_gh) -> tuple[int, int]: def branch_rounds(branch: str, gh=_gh) -> int | None: + """该分支已完成的 CI 轮次:ci_workflows 各工作流的 pull_request 运行,按不同的 head 提交合计。""" try: - runs = json.loads(gh("run", "list", "--workflow", "build", "--branch", branch, "--limit", "100", - "--json", "headSha,status,event")) + runs = [] + for workflow in ci_workflows(): + runs += json.loads(gh("run", "list", "--workflow", workflow, "--branch", branch, "--limit", "100", + "--json", "headSha,status,event")) except (RuntimeError, json.JSONDecodeError, OSError): return None return run_check.ci_rounds([run for run in runs if run.get("event") == "pull_request"]) def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dict | None = None, gh=_gh, - branch: str = "") -> Facts: + branch: str = "", trace: str | None = None) -> Facts: autonomy = autonomy or r1_checks.load_autonomy() - report = risk.classify(base, head, cwd) + report = risk.classify(base, head, cwd, trace_id=trace) shas = git("rev-list", f"{base}..{head}", cwd=cwd).split() defects = any(commit_field(sha, "Defect", cwd) for sha in shas) facts = Facts(report, machine_class(report, defects)) @@ -239,6 +243,71 @@ def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dic return facts +def _event_trace(pr: int | None, branch: str) -> str | None: + """事件 trace:--branch 优先;其次有 PR 时取 PR API 的 headRefName(不读 PR 正文);否则 current_trace()。 + + 只在事件开启时解析;查询失败回退 current_trace(),不改变原有判定与调用序列。 + """ + if not events.enabled(): + return None + if branch: + return branch + if pr is not None: + try: + name = json.loads(_gh("pr", "view", str(pr), "--json", "headRefName")).get("headRefName") + if name: + return str(name) + except (RuntimeError, json.JSONDecodeError, KeyError, TypeError, OSError): + pass + return events.current_trace() + + +def _config_refs() -> list[dict]: + """main 上 autonomy.toml 与 rules.toml 的带提交引用(路径@HEAD + 内容 sha256);单侧失败只少记一条。""" + refs = [] + for kind, path in (("autonomy", r1_checks.AUTONOMY_PATH), ("rules", RULES_PATH)): + entry = _config_ref(kind, path) + if entry: + refs.append(entry) + return refs + + +def _config_ref(kind: str, path: Path) -> dict | None: + """单个配置文件的带提交引用;读取或定位失败返回 None(观察旁路)。""" + try: + content = path.read_bytes() + rev = git("rev-parse", "HEAD", cwd=path.parent) + return {"kind": kind, "ref": f"{path.relative_to(path.parents[2]).as_posix()}@{rev}", + "sha256": hashlib.sha256(content).hexdigest(), "size": len(content)} + except Exception: # noqa: BLE001 观察旁路:引用准备失败只少记这条引用 + return None + + +def _emit_route(trace: str | None, facts: Facts, rules: list[Rule], auto: bool, audit: bool) -> None: + """路由观察事件(C1:facts、每条规则、result);事件不进判定,配置引用读取失败只少记引用。""" + if not events.enabled(): + return + try: + refs = _config_refs() + except Exception: # noqa: BLE001 观察旁路:引用准备失败不影响判定与输出 + refs = [] + events.emit("route", "facts", "ok", trace_id=trace, inputs=refs, + outputs={"risk": facts.risk.label, "machine_class": facts.machine_class, + "declared_class": facts.declared_class, "changed_lines": facts.changed_lines, + "escapes": facts.escapes, "window": facts.window}) + for rule in rules: + events.emit("route", rule.name, "ok" if rule.ok else "fail", trace_id=trace, inputs=refs, + outputs={"ok": rule.ok}, + decision={"by": "policy", "rule": rule.name, "reason": rule.reason}) + try: + approval = platform_outputs()["approval"] + except Exception: # noqa: BLE001 观察旁路:批准方式配置读不到就不记这个字段 + approval = None + events.emit("route", "result", "ok" if auto else "deny", trace_id=trace, inputs=refs, + outputs={"auto_merge": auto, "audit": audit, "approval": approval}, + decision={"by": "policy", "rule": "result", "reason": "自动合并" if auto else "转用户评审"}) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -248,11 +317,13 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--github", action="store_true", help="写 GITHUB_STEP_SUMMARY 与 GITHUB_OUTPUT") args = parser.parse_args(argv) autonomy = r1_checks.load_autonomy() - facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch) + trace = _event_trace(args.pr, args.branch) + facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch, trace=trace) rules = decide(facts, autonomy) auto = all(rule.ok for rule in rules) every = autonomy.get("classes", {}).get(facts.machine_class, {}).get("audit_every", 0) audit = auto and args.pr is not None and audit_sampled(args.pr, every) + _emit_route(trace, facts, rules, auto, audit) text = render(rules, facts, audit) print(text) print(risk.render_markdown(facts.risk)) diff --git a/.harness/engine/routing/risk.py b/.harness/engine/routing/risk.py index 0ea614b..45a9d77 100644 --- a/.harness/engine/routing/risk.py +++ b/.harness/engine/routing/risk.py @@ -24,6 +24,7 @@ from pathlib import Path from engine.checks import r1_checks, taskbook +from engine.core import events from engine.core.common import ( ROOT, added_line_count, @@ -76,24 +77,32 @@ def label(self) -> str: return f"R{self.level}" -def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path) -> tuple[FileRisk, str | None]: +def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path, + *, trace_id: str | None = None) -> tuple[FileRisk, str | None]: + """判定单个文件的等级;trace_id 非空时附带写逐文件观察事件(路径、命中规则、等级),不影响返回值。""" risk = rules["risk"] + + def record(rule: str, item: FileRisk, flag: str | None) -> tuple[FileRisk, str | None]: + events.emit("route", "risk.file", "ok", trace_id=trace_id, + outputs={"path": item.path, "status": item.status, "level": item.level}, + decision={"by": "risk", "rule": rule, "reason": item.reason}) + return item, flag + if path_matches(path, risk["golden"]): if status == "A": - return FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None - return FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`" + return record("golden", FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None) + return record("golden", FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`") if path_matches(path, risk["tests"]): if status == "A": - return FileRisk(path, status, 0, "新增测试"), None + return record("tests", FileRisk(path, status, 0, "新增测试"), None) if status == "D": - return FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`" + return record("tests", FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`") removed = removed_line_count(base, head, path, cwd) if removed == 0: - return FileRisk(path, status, 0, "只在已有测试文件中追加"), None - return ( - FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), - f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认", - ) + return record("tests", FileRisk(path, status, 0, "只在已有测试文件中追加"), None) + return record("tests", + FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), + f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认") shrinking = ( status == "M" and path_matches(path, risk.get("shrink_only", [])) @@ -101,22 +110,22 @@ def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd and removed_line_count(base, head, path, cwd) ) if shrinking: - return FileRisk(path, status, 0, "只能缩减的清单被缩减"), None + return record("shrink_only", FileRisk(path, status, 0, "只能缩减的清单被缩减"), None) pattern = path_matches(path, risk["r3"]) if pattern: - return FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None) pattern = path_matches(path, risk.get("contracts", [])) if pattern: - return FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None + return record(pattern, FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None) if path_matches(path, risk.get("taskbooks", [])): - return classify_taskbook(status, path, head, cwd), None + return record("taskbook", classify_taskbook(status, path, head, cwd), None) pattern = path_matches(path, risk["r0"]) if pattern: - return FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None) pattern = path_matches(path, risk["r2"]) if pattern: - return FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None - return FileRisk(path, status, 2, "未归类路径按 R2"), None + return record(pattern, FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None) + return record("unclassified", FileRisk(path, status, 2, "未归类路径按 R2"), None) def classify_taskbook(status: str, path: str, head: str, cwd: Path) -> FileRisk: @@ -147,11 +156,13 @@ def commits_claim_r1(base: str, head: str, cwd: Path) -> bool: return True -def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None = None) -> RiskReport: +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()(旧调用仍有效)。""" rules = rules or load_rules() report = RiskReport() for status, path in changed_files(base, head, cwd): - file_risk, flag = classify_file(status, path, base, head, rules, cwd) + file_risk, flag = classify_file(status, path, base, head, rules, cwd, trace_id=trace_id) report.files.append(file_risk) if flag: report.flags.append(flag) @@ -193,9 +204,21 @@ def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None report.r1_violations = strengthened report.notes.append("提交声明了 `Risk: R1`,但机器核对不满足(见上方标记、非代码路径或下列理由)→ 维持原等级") report.notes += [f"- {reason}" for reason in strengthened] + _emit_summary(report, base, head, trace_id) return report +def _emit_summary(report: RiskReport, base: str, head: str, trace_id: str | None) -> None: + """风险汇总观察事件:最终等级、R1 声明与是否被降级、被改动的已有测试数(观察旁路,不影响判定)。""" + changed_tests = sum(1 for item in report.files if item.reason.startswith("改动已有测试")) + events.emit("route", "risk.summary", "ok", trace_id=trace_id, + inputs=[events.ref("rev", base), events.ref("rev", head)], + outputs={"risk": report.label, "level": report.level, "claimed_r1": report.claimed_r1, + "downgraded": report.claimed_r1 and report.level != 1, + "changed_tests": changed_tests}, + decision={"by": "risk", "rule": "risk", "reason": f"{report.label}:{POLICY[report.level]}"}) + + def render_markdown(report: RiskReport) -> str: lines = [f"### 风险等级:**{report.label}** — {POLICY[report.level]}", ""] if report.flags: diff --git a/.harness/engine/routing/run_check.py b/.harness/engine/routing/run_check.py index 5d02ad7..295d44b 100644 --- a/.harness/engine/routing/run_check.py +++ b/.harness/engine/routing/run_check.py @@ -9,9 +9,9 @@ 归属 范围内每个非合并提交都带 `Task: <该任务书的编号>` 运行记录 新增了 docs/runs/<任务书名>/<序号>.json;序号最大的一份格式完整、task/class/branch 与任务书一致、 exit 为 ok,提示词文件存在且 sha256 与记录一致 - CI 轮次 该分支已完成的 build 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds + CI 轮次 该分支已完成的 CI 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds -权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);build 的 harness job 只把结果写进 summary。 +权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);CI 的 harness job 只把结果写进 summary。 bin/harness run-check --base origin/main [--head HEAD] [--branch task/005-x] [--github] """ @@ -28,6 +28,7 @@ from pathlib import Path from engine.checks import taskbook +from engine.core import events from engine.core.common import ROOT, changed_files, commit_field, git RECORD_FIELDS = ( @@ -131,7 +132,7 @@ def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> def ci_rounds(runs: list[dict]) -> int: - """已完成的 build 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" + """已完成的 CI 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" return len({run.get("headSha") or run.get("head_sha") for run in runs if run.get("status") == "completed" and (run.get("headSha") or run.get("head_sha"))}) @@ -139,30 +140,51 @@ def ci_rounds(runs: list[dict]) -> int: def check_ci(rounds: int | None, header: dict) -> Finding: limit = (header.get("budget") or {}).get("ci_rounds") if rounds is None: - return Finding("CI 轮次", False, "读不到该分支的 build 运行,按超预算处理") + return Finding("CI 轮次", False, "读不到该分支的 CI 运行,按超预算处理") if not isinstance(limit, int): return Finding("CI 轮次", False, "任务书没有 budget.ci_rounds") ok = rounds <= limit return Finding("CI 轮次", ok, f"已完成 {rounds} 轮,预算 {limit} 轮" + ("" if ok else ":超出预算")) +def _record(target: Scope | None, findings: list[Finding] | None) -> None: + """观察旁路:适用性与每个 Finding 一条事件(规则、通过与原因);失败不影响返回值。""" + try: + if target is None: + events.emit(stage="ci", step="run_check", status="skip", outputs={"applicable": False}) + return + events.emit(stage="ci", step="run_check", status="ok", + outputs={"applicable": True, "taskbook": target.taskbook or None, + "task": target.header.get("task") or None, "in_pr": target.in_pr}) + for finding in findings or []: + events.emit(stage="ci", step="run_check.finding", status="ok" if finding.ok else "fail", + outputs={"finding": finding.name, "ok": finding.ok}, + decision={"by": "run_check", "rule": finding.name, "reason": finding.reason}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def check(base: str, head: str, branch: str, cwd: Path = ROOT, rounds: int | None = None, with_ci: bool = True) -> list[Finding] | None: """不适用(不是在实现任务书)时返回 None。""" target = scope(base, head, branch, cwd) if target is None: + _record(None, None) # 观察:不适用 return None task_id = target.header.get("task", "") findings = [check_trailers(base, head, task_id, cwd)] if not target.taskbook: findings.append(Finding("任务书", False, f"`Task: {task_id}` 找不到对应的任务书")) + _record(target, findings) return findings if target.in_pr: findings.append(Finding("运行记录", True, f"任务书 `{target.taskbook}` 随本 PR 提交:设计方自行实现,不要求运行记录")) + _record(target, findings) return findings findings.append(check_record(base, head, target, branch, cwd)) if with_ci: findings.append(check_ci(rounds, target.header)) + _record(target, findings) # 观察:适用性与各项发现 return findings @@ -182,7 +204,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--github", action="store_true", help="写 GITHUB_STEP_SUMMARY(只报告,不失败)") args = parser.parse_args(argv) branch = args.branch or os.environ.get("GITHUB_HEAD_REF") or git("rev-parse", "--abbrev-ref", args.head) - text = render(check(args.base, args.head, branch, with_ci=False)) + text = render(check(args.base, args.head, branch, cwd=ROOT, with_ci=False)) print(text) if args.github and os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as handle: