From 5618cfc8cf5930a6363c3d0f6e0c6d5d5a3a076b Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 09:25:38 +0000 Subject: [PATCH] feat(tooling): add Cursor governance hooks, skills, and rule pointers Layer advisory Cursor hooks and skills on the PI executor, shrink the test-report rule to the validation contract, and fix MCP relative paths. --- .cursor/hooks.json | 37 ++ .cursor/hooks/_lib.py | 152 ++++++++ .cursor/hooks/audit-session.sh | 4 + .cursor/hooks/audit_session.py | 101 +++++ .cursor/hooks/guard-pi-dispatch.sh | 5 + .cursor/hooks/guard-pi-shell.sh | 4 + .cursor/hooks/guard_pi_dispatch.py | 113 ++++++ .cursor/hooks/guard_pi_shell.py | 56 +++ .cursor/hooks/pi-receipt-context.sh | 4 + .cursor/hooks/pi_receipt_context.py | 48 +++ .cursor/mcp.json | 4 +- .cursor/rules/agentctl-mandatory.mdc | 7 +- .cursor/rules/cursor-hooks.mdc | 40 ++ .cursor/rules/pi-subagents.mdc | 3 +- .cursor/rules/test-reports-standard.mdc | 38 +- .cursor/skills/pi-subagent-dispatch/SKILL.md | 77 ++++ .cursor/skills/subagent-stage-record/SKILL.md | 56 +++ .../2026-09-24-cursor-hook-event-contract.md | 41 ++ .doc/audit/ISSUES.md | 7 +- .doc/ops/2026-09-21-policy-registry.md | 9 + .doc/ops/CURRENT.md | 1 + .../2026-09-24-cursor-hook-advisory-gates.md | 28 ++ .doc/ops/lessons/INDEX.md | 1 + .../2026-09-24-cursor-governance-hooks.md | 80 ++++ ...-24-cursor-governance-hooks-test-report.md | 79 ++++ .../probe-status.json | 22 ++ .../unittest-discover.txt | 279 ++++++++++++++ scripts/tests/test_cursor_hooks.py | 352 ++++++++++++++++++ 28 files changed, 1619 insertions(+), 29 deletions(-) create mode 100644 .cursor/hooks/_lib.py create mode 100755 .cursor/hooks/audit-session.sh create mode 100644 .cursor/hooks/audit_session.py create mode 100755 .cursor/hooks/guard-pi-dispatch.sh create mode 100755 .cursor/hooks/guard-pi-shell.sh create mode 100644 .cursor/hooks/guard_pi_dispatch.py create mode 100644 .cursor/hooks/guard_pi_shell.py create mode 100755 .cursor/hooks/pi-receipt-context.sh create mode 100644 .cursor/hooks/pi_receipt_context.py create mode 100644 .cursor/rules/cursor-hooks.mdc create mode 100644 .cursor/skills/pi-subagent-dispatch/SKILL.md create mode 100644 .cursor/skills/subagent-stage-record/SKILL.md create mode 100644 .doc/audit/2026-09-24-cursor-hook-event-contract.md create mode 100644 .doc/ops/lessons/2026-09-24-cursor-hook-advisory-gates.md create mode 100644 .doc/ops/short-term/2026-09-24-cursor-governance-hooks.md create mode 100644 .doc/test-reports/2026-09-24-cursor-governance-hooks-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-24-cursor-governance-hooks/probe-status.json create mode 100644 .doc/test-reports/assets/2026-09-24-cursor-governance-hooks/unittest-discover.txt create mode 100644 scripts/tests/test_cursor_hooks.py diff --git a/.cursor/hooks.json b/.cursor/hooks.json index a8abf88b..3d766b6f 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -5,7 +5,44 @@ { "command": ".cursor/hooks/guard-git.sh", "matcher": "^git ", + "timeout": 10, "failClosed": true + }, + { + "command": ".cursor/hooks/guard-pi-shell.sh", + "matcher": "pi_subagents\\.py|pkill", + "timeout": 10, + "failClosed": true + } + ], + "beforeMCPExecution": [ + { + "command": ".cursor/hooks/guard-pi-dispatch.sh", + "matcher": "pi-subagents|pi_subagent_", + "timeout": 10, + "failClosed": true + } + ], + "postToolUse": [ + { + "command": ".cursor/hooks/pi-receipt-context.sh", + "matcher": "MCP: pi-subagents|pi_subagent_", + "timeout": 10, + "failClosed": false + } + ], + "sessionStart": [ + { + "command": ".cursor/hooks/audit-session.sh", + "timeout": 20, + "failClosed": false + } + ], + "stop": [ + { + "command": ".cursor/hooks/audit-session.sh", + "timeout": 10, + "failClosed": false } ] } diff --git a/.cursor/hooks/_lib.py b/.cursor/hooks/_lib.py new file mode 100644 index 00000000..b08429e6 --- /dev/null +++ b/.cursor/hooks/_lib.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Shared helpers for project Cursor hooks (advisory gates + audit/probe).""" +from __future__ import annotations + +import json +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +KNOWN_ROLES = frozenset( + { + "research", + "planning", + "requirements_review", + "plan_review", + "code_review", + "diagnosis", + "test_run", + "acceptance_check", + "stage_notes", + "report", + "auxiliary", + } +) +TEST_ROLES = frozenset({"test_run", "acceptance_check"}) +FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$") +REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +def repo_root() -> Path: + env = os.environ.get("CURSOR_PROJECT_DIR") or os.environ.get("CURSOR_WORKSPACE_ROOT") + if env: + return Path(env).resolve() + # hooks run with cwd = project root + return Path.cwd().resolve() + + +def audit_path() -> Path: + return repo_root() / ".cursor" / "hook-audit.jsonl" + + +def probe_path() -> Path: + return repo_root() / ".cursor" / "hook-probe.jsonl" + + +def inventory_dir() -> Path: + return repo_root() / ".cursor" / "hook-audit" + + +def append_jsonl(path: Path, record: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + record = dict(record) + record.setdefault("ts", datetime.now(timezone.utc).isoformat()) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def read_stdin() -> str: + return sys.stdin.read() + + +def maybe_probe(raw: str, hook: str) -> None: + if os.environ.get("CURSOR_HOOKS_PROBE", "").strip() not in ("1", "true", "TRUE", "yes"): + return + append_jsonl( + probe_path(), + {"hook": hook, "raw": raw[:65536], "raw_len": len(raw)}, + ) + + +def parse_json_loose(raw: str) -> dict[str, Any] | None: + raw = (raw or "").strip() + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def first_key(data: dict[str, Any], *names: str) -> Any: + for name in names: + if name in data and data[name] is not None: + return data[name] + return None + + +def tool_name(data: dict[str, Any]) -> str | None: + v = first_key(data, "tool_name", "mcp_tool_name", "name", "toolName") + return str(v) if v is not None else None + + +def mcp_server(data: dict[str, Any]) -> str | None: + v = first_key(data, "mcp_server_name", "server", "mcpServer", "server_name") + return str(v) if v is not None else None + + +def tool_input(data: dict[str, Any]) -> dict[str, Any] | None: + v = first_key(data, "tool_input", "arguments", "input", "toolInput", "params") + if isinstance(v, dict): + return v + if isinstance(v, str): + try: + parsed = json.loads(v) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + return None + return None + + +def shell_command(data: dict[str, Any]) -> str: + v = first_key(data, "command", "shell_command", "cmd") + return str(v) if v is not None else "" + + +def emit(obj: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(obj, ensure_ascii=False)) + sys.stdout.flush() + + +def allow() -> None: + emit({"permission": "allow"}) + raise SystemExit(0) + + +def ask(user_message: str, agent_message: str) -> None: + emit( + { + "permission": "ask", + "user_message": user_message, + "agent_message": agent_message, + } + ) + raise SystemExit(0) + + +def additional_context(text: str) -> None: + emit({"additional_context": text}) + raise SystemExit(0) + + +def audit(hook: str, code: str, **extra: Any) -> None: + rec = {"hook": hook, "code": code} + rec.update(extra) + try: + append_jsonl(audit_path(), rec) + except OSError: + pass diff --git a/.cursor/hooks/audit-session.sh b/.cursor/hooks/audit-session.sh new file mode 100755 index 00000000..0b7e7546 --- /dev/null +++ b/.cursor/hooks/audit-session.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 -B "$DIR/audit_session.py" diff --git a/.cursor/hooks/audit_session.py b/.cursor/hooks/audit_session.py new file mode 100644 index 00000000..bbe7d66b --- /dev/null +++ b/.cursor/hooks/audit_session.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""sessionStart / stop: audit + optional fail-open inventory (no context injection).""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib as lib # noqa: E402 + +HOOK = "audit-session" +INVENTORY_TIMEOUT = 15 + + +def _run_inventory(root: Path) -> dict: + out: dict = {"cleanup_report": None, "docs_drift": None, "errors": []} + if os.environ.get("CURSOR_HOOKS_NO_INVENTORY", "").strip() in ("1", "true", "TRUE", "yes"): + out["skipped"] = "CURSOR_HOOKS_NO_INVENTORY" + return out + agentctl = root / "scripts" / "agentctl.py" + if not agentctl.is_file(): + out["errors"].append("agentctl.py missing") + return out + for label, args in ( + ("cleanup_report", ["cleanup", "report"]), + ("docs_drift", ["docs", "drift"]), + ): + try: + r = subprocess.run( + ["python3", "-B", str(agentctl), *args], + cwd=str(root), + capture_output=True, + text=True, + timeout=INVENTORY_TIMEOUT, + check=False, + ) + payload = None + for stream in (r.stdout, r.stderr): + for line in (stream or "").splitlines(): + line = line.strip() + if line.startswith("{"): + try: + payload = json.loads(line) + break + except json.JSONDecodeError: + continue + if payload is not None: + break + out[label] = { + "exit_code": r.returncode, + "payload": payload, + "stdout_tail": (r.stdout or "")[-2000:], + "stderr_tail": (r.stderr or "")[-1000:], + } + except subprocess.TimeoutExpired: + out["errors"].append(f"{label}: timeout>{INVENTORY_TIMEOUT}s") + except OSError as exc: + out["errors"].append(f"{label}: {exc}") + return out + + +def main() -> None: + raw = lib.read_stdin() + lib.maybe_probe(raw, HOOK) + data = lib.parse_json_loose(raw) or {} + event = str( + lib.first_key(data, "hook_event_name", "event", "event_name", "type") or "unknown" + ) + root = lib.repo_root() + if "start" in event.lower() or event == "unknown": + inv = _run_inventory(root) + inv_path = None + try: + inv_dir = lib.inventory_dir() + inv_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + path = inv_dir / f"inventory-{ts}.json" + path.write_text(json.dumps(inv, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + inv_path = str(path) + except OSError: + pass + lib.audit( + HOOK, + "SESSION_AUDIT", + event=event, + inventory_path=inv_path, + inventory_errors=inv.get("errors"), + ) + else: + lib.audit(HOOK, "SESSION_AUDIT", event=event) + + lib.emit({}) + raise SystemExit(0) + + +if __name__ == "__main__": + main() diff --git a/.cursor/hooks/guard-pi-dispatch.sh b/.cursor/hooks/guard-pi-dispatch.sh new file mode 100755 index 00000000..7f9c1cdb --- /dev/null +++ b/.cursor/hooks/guard-pi-dispatch.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Thin wrapper: preserve stdin for Cursor hook JSON (do not use heredoc). +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 -B "$DIR/guard_pi_dispatch.py" diff --git a/.cursor/hooks/guard-pi-shell.sh b/.cursor/hooks/guard-pi-shell.sh new file mode 100755 index 00000000..a6ce0786 --- /dev/null +++ b/.cursor/hooks/guard-pi-shell.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 -B "$DIR/guard_pi_shell.py" diff --git a/.cursor/hooks/guard_pi_dispatch.py b/.cursor/hooks/guard_pi_dispatch.py new file mode 100644 index 00000000..0ee51def --- /dev/null +++ b/.cursor/hooks/guard_pi_dispatch.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Advisory beforeMCPExecution gate for pi-subagents MCP tools.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib as lib # noqa: E402 + +HOOK = "guard-pi-dispatch" + + +def main() -> None: + raw = lib.read_stdin() + lib.maybe_probe(raw, HOOK) + data = lib.parse_json_loose(raw) + if data is None: + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="non_json_or_empty") + lib.allow() + + name = lib.tool_name(data) + server = lib.mcp_server(data) + if not name: + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="missing_tool_name", keys=sorted(data.keys())[:40]) + lib.allow() + + if server and "pi-subagents" not in server and not name.startswith("pi_subagent_"): + if not name.startswith("pi_subagent_"): + lib.allow() + + if name in ("pi_subagent_roles", "pi_subagent_result"): + lib.allow() + + if name != "pi_subagent_run": + if name.startswith("pi_subagent_"): + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="unknown_pi_tool", tool=name) + lib.allow() + + args = lib.tool_input(data) or {} + req = args.get("request") if isinstance(args.get("request"), dict) else args + if not isinstance(req, dict): + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="missing_request") + lib.ask( + "pi_subagent_run 缺少可识别的 request 对象。请核对 MCP 参数后再继续。", + "Hook: unrecognized request shape for pi_subagent_run.", + ) + + role = req.get("role_id") + request_id = req.get("request_id") + goal = req.get("goal") + auth = req.get("authorization_ref") + source_sha = req.get("source_sha") + input_files = req.get("input_files") + timeout = req.get("timeout_seconds") + + problems: list[str] = [] + + if not role or not isinstance(role, str): + problems.append("role_id 缺失") + elif role not in lib.KNOWN_ROLES: + problems.append(f"role_id={role!r} 不在已知角色表") + + if not request_id or not isinstance(request_id, str) or not lib.REQUEST_ID.match(request_id): + problems.append("request_id 非法(需字母数字开头,长度≤128)") + + if not goal or not isinstance(goal, str) or not goal.strip(): + problems.append("goal 为空") + + if not auth or not isinstance(auth, str) or not auth.strip(): + problems.append("authorization_ref 为空") + + if not isinstance(source_sha, str) or not lib.FULL_SHA.match(source_sha): + problems.append("source_sha 必须是完整 40-hex 提交(禁止 HEAD/分支名/短 hash)") + + if not isinstance(input_files, list): + problems.append("input_files 必须是数组") + + if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0: + if isinstance(timeout, float) and timeout.is_integer() and timeout > 0: + pass + else: + problems.append("timeout_seconds 必须是正整数") + + if isinstance(role, str) and role in lib.TEST_ROLES: + cmds = req.get("approved_commands") + digest = req.get("plan_digest") + if not isinstance(cmds, list) or not cmds: + problems.append("test 角色需要 approved_commands(argv 数组的数组)") + elif any(not isinstance(c, list) or not all(isinstance(x, str) for x in c) for c in cmds): + problems.append("approved_commands 形状错误(应为字符串数组的数组)") + if not isinstance(digest, str) or len(digest) != 64 or any( + c not in "0123456789abcdefABCDEF" for c in digest + ): + problems.append("plan_digest 必须是 64-hex SHA-256") + + if "model_override" in req and req.get("model_override") is not None: + problems.append("出现 model_override(禁止静默换模型;请用任务 override)") + + if problems: + detail = ";".join(problems) + lib.audit(HOOK, "SHAPE_ASK", tool=name, problems=problems, role=role, request_id=request_id) + lib.ask( + f"pi_subagent_run 形状检查未通过:{detail}。执行器仍会硬校验;确认后继续或修正参数。", + f"Hook advisory: {detail}", + ) + + lib.audit(HOOK, "ALLOW", tool=name, role=role, request_id=request_id) + lib.allow() + + +if __name__ == "__main__": + main() diff --git a/.cursor/hooks/guard_pi_shell.py b/.cursor/hooks/guard_pi_shell.py new file mode 100644 index 00000000..0008033f --- /dev/null +++ b/.cursor/hooks/guard_pi_shell.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Advisory beforeShellExecution: bare pi_subagents.py run / broad pkill.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib as lib # noqa: E402 + +HOOK = "guard-pi-shell" + + +def main() -> None: + raw = lib.read_stdin() + lib.maybe_probe(raw, HOOK) + data = lib.parse_json_loose(raw) + if data is None: + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="non_json_or_empty") + lib.allow() + + cmd = lib.shell_command(data) + if not cmd: + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="missing_command", keys=sorted(data.keys())[:40]) + lib.allow() + + compact = re.sub(r"\s+", " ", cmd).strip() + + if re.search(r"(^|[;&|]\s*)pkill(\s|$)", compact) and re.search(r"\s-f(\s|$)", compact): + lib.audit(HOOK, "PKILL_ASK", command=compact[:500]) + lib.ask( + "检测到 pkill -f(广谱杀进程)。AGENTS/PI 规则禁止广谱杀;请确认范围后再继续。", + "Hook requires confirmation for pkill -f.", + ) + + if re.search(r"pi_subagents\.py(\s|$)", compact) and re.search(r"(^|\s)run(\s|$)", compact): + has_lease = ( + re.search(r"agentctl(\.py)?\s+with\b", compact) is not None + and re.search(r"--resource\s+path:", compact) is not None + ) + if re.search(r"pi_subagents\.py\s+(roles|result|status|config)\b", compact): + lib.allow() + if not has_lease: + lib.audit(HOOK, "BARE_RUN_ASK", command=compact[:500]) + lib.ask( + "裸调 pi_subagents.py run 缺少 agentctl with --resource path:… 租约。" + "报告路径须在 path:.doc/test-reports/assets/pi-subagents// 租约内。", + "Hook: bare PI run without lease resource.", + ) + + lib.allow() + + +if __name__ == "__main__": + main() diff --git a/.cursor/hooks/pi-receipt-context.sh b/.cursor/hooks/pi-receipt-context.sh new file mode 100755 index 00000000..21579650 --- /dev/null +++ b/.cursor/hooks/pi-receipt-context.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 -B "$DIR/pi_receipt_context.py" diff --git a/.cursor/hooks/pi_receipt_context.py b/.cursor/hooks/pi_receipt_context.py new file mode 100644 index 00000000..48259ff2 --- /dev/null +++ b/.cursor/hooks/pi_receipt_context.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""postToolUse: inject receipt-check reminders after pi-subagents tools.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib as lib # noqa: E402 + +HOOK = "pi-receipt-context" + +REMINDER = """[PI subagent receipt checklist] +- 核对 report/receipt 路径:.doc/test-reports/assets/pi-subagents/// +- 记录 config_revision / config_sha256 / configured vs actual_model(不可观察填 unknown) +- run_status=succeeded 不等于阶段 PASS;review_state 仍可为 pending +- 测试 pass 只来自批准命令证据(approved_commands),不能用模型叙事冒充 +- 推进门禁前用 subagent-stage-record skill 写回传与阶段收据 +""" + + +def main() -> None: + raw = lib.read_stdin() + lib.maybe_probe(raw, HOOK) + data = lib.parse_json_loose(raw) + if data is None: + lib.audit(HOOK, "UNRECOGNIZED_INPUT", reason="non_json_or_empty") + lib.emit({}) + raise SystemExit(0) + + name = lib.tool_name(data) or "" + server = lib.mcp_server(data) or "" + blob = raw + is_pi = ( + name.startswith("pi_subagent_") + or "pi-subagents" in server + or "pi_subagent_" in blob + ) + if not is_pi: + lib.emit({}) + raise SystemExit(0) + + lib.audit(HOOK, "CONTEXT_INJECT", tool=name or "unknown") + lib.additional_context(REMINDER) + + +if __name__ == "__main__": + main() diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 80913cba..5c0bb961 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,11 +2,11 @@ "mcpServers": { "agentctl": { "command": "node", - "args": ["${workspaceFolder}/scripts/mcp/agentctl-mcp.mjs"] + "args": ["scripts/mcp/agentctl-mcp.mjs"] }, "jev": { "command": "node", - "args": ["${workspaceFolder}/scripts/mcp/jev-mcp.mjs"] + "args": ["scripts/mcp/jev-mcp.mjs"] }, "codegraph": { "type": "stdio", diff --git a/.cursor/rules/agentctl-mandatory.mdc b/.cursor/rules/agentctl-mandatory.mdc index 18124b69..5358483b 100644 --- a/.cursor/rules/agentctl-mandatory.mdc +++ b/.cursor/rules/agentctl-mandatory.mdc @@ -11,7 +11,7 @@ alwaysApply: true 1. **写仓库**:必须 `agentctl with` / MCP `lease_run`。裸 `git commit` 会被 agentctl pre-commit hook 拒绝。 2. **共享文档**:`.doc/ops/CURRENT.md`、`.doc/status/FEATURE-MATRIX.md` 只能 `docs apply`(行级 CAS;MATRIX 键为 `record_id`)。 -3. **合入 local dev**:`validate` 后 `queue enqueue`(daemon `--ff-only`)或受控 `integrate`;禁止直接改主目录 `dev` tip。 +3. **合入 local dev**:`validate` 后 `queue enqueue`(daemon `--ff-only`)或受控 `integrate`;禁止直接改主目录 `dev` tip。 4. **禁止默认**:未授权 `git push`、晋升 test/main、`--force` 清理、卸载 launchd coordinator、改 gap-matrix / 升 MATRIX 状态值冒充发布。 ## 会话路径 @@ -20,4 +20,7 @@ alwaysApply: true - 建场 → `agentctl-worktree-setup` - 写回 → `agentctl-lease-write` - 合入/收口 → `agentctl-integrate-closeout` -- MCP 工具定义见 `.cursor/mcp.json` → `scripts/mcp/agentctl-mcp.mjs` +- PI 派发 → skill `pi-subagent-dispatch`(MCP `pi-subagents`) +- 阶段落盘 → skill `subagent-stage-record` +- MCP 工具定义见 `.cursor/mcp.json` → `scripts/mcp/agentctl-mcp.mjs` / `pi-subagents-mcp.mjs` +- 客户端 hook 边界见规则 `cursor-hooks` diff --git a/.cursor/rules/cursor-hooks.mdc b/.cursor/rules/cursor-hooks.mdc new file mode 100644 index 00000000..bcf393c0 --- /dev/null +++ b/.cursor/rules/cursor-hooks.mdc @@ -0,0 +1,40 @@ +--- +description: Project Cursor hooks are advisory gates layered on agentctl/PI executor hard checks; scripts exist does not mean Cursor loaded them. +alwaysApply: true +--- + +# Cursor hooks(客户端门禁) + +配置:`.cursor/hooks.json`;脚本:`.cursor/hooks/`。规范登记修订 `cursor-governance-2026-09-24-r1`。 + +## 分层 + +| 层 | 能力 | 说明 | +|---|---|---| +| 执行器 / agentctl | **enforced** | `scripts/pi_subagents.py`、租约、pre-commit 等硬拒绝 | +| hook 脚本行为 | **enforced**(有单元回归) | stdin→stdout 契约可测 | +| hook ↔ Cursor 接线 | **advisory / manual** | 官方事件字段未完全文档化;待 probe 采样后收紧 | + +命中策略:与 `guard-git.sh` 一致,一律 `permission: ask`(不静默 deny)。 + +## 事件与脚本 + +- `beforeShellExecution`:`guard-git.sh`(破坏性 git);`guard-pi-shell.sh`(裸调 `pi_subagents.py run`、`pkill -f`) +- `beforeMCPExecution`:`guard-pi-dispatch.sh`(`pi_subagent_run` 形状) +- `postToolUse`:`pi-receipt-context.sh`(注入收据核对提醒) +- `sessionStart` / `stop`:`audit-session.sh`(审计 + 可选只读盘点;**不注入上下文**) + +## 审计与 probe + +- 审计:`.cursor/hook-audit.jsonl`(gitignore;勿提交) +- 盘点:`.cursor/hook-audit/inventory-*.json` +- 采样:`CURSOR_HOOKS_PROBE=1` → `.cursor/hook-probe.jsonl` +- 关闭盘点:`CURSOR_HOOKS_NO_INVENTORY=1` + +异形/未知字段:**fail-open + 审计 `UNRECOGNIZED_INPUT`**,不因契约未知卡死会话。 + +## 红线 + +- 不把 hook 复刻成第二套执行器门禁 +- 脚本存在 ≠ Cursor 已加载;改 hooks 后检查 Hooks 设置页或通道 +- 审计日志不得放进被 git 跟踪的 `.cursor/hooks/` 目录 diff --git a/.cursor/rules/pi-subagents.mdc b/.cursor/rules/pi-subagents.mdc index 17638f51..63e1baa2 100644 --- a/.cursor/rules/pi-subagents.mdc +++ b/.cursor/rules/pi-subagents.mdc @@ -5,7 +5,7 @@ alwaysApply: true # PI subagents(工程工具) -本机 PI 子任务走 `scripts/pi_subagents.py` + 模型配置 `.doc/ops/2026-09-24-subagent-model-config.md`。 +本机 PI 子任务走 `scripts/pi_subagents.py` + 模型配置 `.doc/ops/2026-09-24-subagent-model-config.md`。可执行步骤见 skill `pi-subagent-dispatch`;阶段记录见 `subagent-stage-record`。 ## 自动派发(主 Agent) @@ -21,3 +21,4 @@ alwaysApply: true - **Cursor Auto**:唯一代码实施车道;本规则不把 implementation 派给 PI。 - **禁止**:自动换模型、M3 关键角色、伪造 lease、递归把本修复任务再派给 PI worker、`pkill -f` 广谱杀进程。 - **MCP**:`.cursor/mcp.json` → `pi-subagents`(roles/run/result);executor 来自 MCP 安装根。 +- **Hook(advisory)**:`.cursor/hooks/guard-pi-dispatch.sh` / `guard-pi-shell.sh` 可在形状错误时 `ask`;**硬拒绝以执行器为准**,见规则 `cursor-hooks`。 diff --git a/.cursor/rules/test-reports-standard.mdc b/.cursor/rules/test-reports-standard.mdc index 8ee51d18..aea6bf87 100644 --- a/.cursor/rules/test-reports-standard.mdc +++ b/.cursor/rules/test-reports-standard.mdc @@ -1,14 +1,13 @@ --- -description: 规定项目中所有自动化测试、功能测试、集成测试报告的存放路径与结构标准规范 -globs: ["**/*test*.go", ".doc/test-reports/**/*.md"] +description: Cursor-side test report path/naming conventions; validation scope comes from the validation contract. alwaysApply: true --- # 测试报告规范准则 (Test Reports Standard) -本仓库严格遵循标准化、可追溯的质量测试管理体系。所有测试报告必须归档于指定的文档目录下,并遵循固定结构。 +本仓库严格遵循标准化、可追溯的质量测试管理体系。**验证范围、套件选择与模型证据的唯一来源**为 [验证契约](../../.doc/ops/2026-09-21-validation-contract.md);本规则只约定 Cursor 侧的报告路径、命名与结构提醒,不另设第二套测试门禁。 -**范围判定**:纯工程规范/文档变更按 [PROJECT.md](../../PROJECT.md) §8 和 [worktree 工作规范](../../.doc/ops/2026-09-21-agent-worktree-workflow.md) 检查链接、规则一致性及 Git 差异;不适用下述产品 PI 全链路与全量 Go 回归要求。报告注明 NOT REQUIRED 及依据,不能记成产品测试 PASS。运行模板、提示词和业务变更不属于此例外。 +**范围判定**:纯工程规范/文档变更按 [PROJECT.md 验证入口](../../PROJECT.md#验证入口) 与 [worktree 工作规范](../../.doc/ops/2026-09-21-agent-worktree-workflow.md) 检查链接、规则一致性及 Git 差异;不适用产品 PI 全链路。报告注明 NOT REQUIRED 及依据,不能记成产品测试 PASS。运行模板、提示词和业务变更不属于此例外——按验证契约选层级。 ## 一、 测试报告归档路径规范 @@ -17,11 +16,9 @@ alwaysApply: true - 例如:`.doc/test-reports/2026-09-07-three-branch-intent-framework-test-report.md` - **证据附件**:运行产物(json/jsonl、workspace 快照、截图)放 `.doc/test-reports/assets//`,**不得**与 `*-test-report.md` 混放在同一层级目录。 ---- - ## 二、 测试报告固定结构模板 -每份测试报告必须包含以下核心板块: +每份独立测试报告应包含以下核心板块(小型文档/脚本任务可在任务主文档设验证节,无需机械拆报告): ```markdown # [功能/模块名称] 测试报告 @@ -30,38 +27,37 @@ alwaysApply: true - 简述被测功能/特性的产生背景、业务痛点或上一轮测试中发现的缺陷与瓶颈。 ## 二、 测试目标 (Test Objectives) -- 列举本次测试需要验证的核心功能点、质量指标(如通过率 100%、无流程卡点、非阻塞人机协同等)。 +- 列举本次测试需要验证的核心功能点、质量指标。 ## 三、 测试方案与环境 (Test Plan & Environment) -- 测试环境(OS、Go 版本、后端 Stub/Real 模型); -- 测试层级分层(单元测试、协同集成测试、CLI 真实交互测试); +- 测试环境(OS、工具版本、目标环境批准配置); +- 测试层级分层(按验证契约矩阵选行); - 核心测试用例矩阵设计。 ## 四、 预期结果 (Expected Results) -- 明确给出各用例输入下的理论判定分支、目标意图、提取槽位、参谋回复风格与状态机流转。 +- 明确给出各用例输入下的理论判定。 ## 五、 实际测试结果与判定 (Actual Results & Evaluation) - 必须包含针对每一个典型测试用例的真实记录: - **测试输入 (Input)** - - **实际输出 (Actual Output)**:贴出真实的终端/日志输出文本 - - **评价判定 (Evaluation / Verdict)**:PASS / FAIL 标志及判定依据 + - **实际输出 (Actual Output)**:贴出真实的终端/日志输出文本(可脱敏) + - **评价判定 (Evaluation / Verdict)**:PASS / FAIL / SKIP / 未运行 - **性能与耗时 (Duration & Latency)** ## 六、 自动化测试套件汇总 (Test Suite Execution) -- 附带 `go test -v ./...` 全量回归测试输出与通过率统计。 +- 只列本次适用并**实际运行**的检查与通过率;不无条件要求全量 `go test`。 +- 工程工具改动优先 `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' -v`(见验证契约「工程工具验收补充」)。 ## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) -- 总结测试是否达到上线/合并标准,提出后续演进或强化点。 +- 总结测试是否达到授权交付终点,提出后续演进或强化点。 ``` ---- - ## 三、 执行要求与真实 Agent 交付铁律 1. **真实 Agent 交付铁律(红线)**: - - **开发实现阶段**:可以快速使用 `stub` 验证状态机、协议分流、数据落盘与逻辑契约,加速反馈; - - **交付前验收与测试报告生成**:**必须切回真实环境与真实 Agent(`pi-agent` + 实际运行的 `MiniMax-M2.7-highspeed` 容器)进行全链路端到端实测**; - - **严禁**使用 stub 的测试输出作为最终交付报告的验收数据。 -2. **严禁散落存放**:测试报告统一归档至 `.doc/test-reports/` 目录下,禁止散落在根目录或临时沙箱中。 + - **开发实现阶段**:可以快速使用 stub 验证状态机、协议分流、数据落盘与逻辑契约,加速反馈; + - **交付前验收与测试报告生成**:涉及产品 Agent/工作流时,必须按验证契约「产品 PI 基线」在获授权环境用真实产品 PI Agent Runner 全链路实测;模型以目标环境批准配置为准,不得在本规则中硬编码模型名; + - **严禁**使用 stub、SKIP、未运行输出作为最终交付报告的验收数据。 +2. **严禁散落存放**:测试报告统一归档至 `.doc/test-reports/`,禁止散落在根目录或临时沙箱中。 3. **真实控制台取样**:测试报告中的输入、输出、耗时与判定必须取自实际环境运行的真实控制台输出与日志。 4. **Git 规范管理**:测试报告伴随对应阶段的 `docs(test)` 或 `test(...)` 规范提交入库。 diff --git a/.cursor/skills/pi-subagent-dispatch/SKILL.md b/.cursor/skills/pi-subagent-dispatch/SKILL.md new file mode 100644 index 00000000..2088f1e2 --- /dev/null +++ b/.cursor/skills/pi-subagent-dispatch/SKILL.md @@ -0,0 +1,77 @@ +--- +name: pi-subagent-dispatch +description: Dispatch gmktec PI analysis/review/test subagents via project MCP pi-subagents (roles/run/result). Use when delegating research, planning, reviews, diagnosis, test_run, acceptance_check, or report work—not for code implementation. +--- + +# PI Subagent 派发 + +权威:[模型配置](../../../.doc/ops/2026-09-24-subagent-model-config.md)、[执行器说明](../../../.doc/ops/2026-09-24-pi-subagent-executor.md)、[流程规范](../../../.doc/ops/2026-09-24-subagent-workflow-standard.md)。客户端规则:[pi-subagents](../../rules/pi-subagents.mdc)。阶段落盘:[subagent-stage-record](../subagent-stage-record/SKILL.md)。 + +**Cursor Auto 独占代码实施与修复**;本 skill 只走本机 PI 子任务。 + +## 调用序列 + +1. `pi_subagent_roles` — 只读预览角色/模型(不调 PI)。 +2. 选 `role_id`(research → planning → reviews → diagnosis → test_run / acceptance_check → report)。 +3. `pi_subagent_run` — 在 agentctl 租约下异步启动。 +4. `pi_subagent_result` — 取回收据;轮询至终态。 + +CLI 等价:`python3 -B scripts/pi_subagents.py roles|run|result`;`run` 必须包在 `agentctl with --resource path:.doc/test-reports/assets/pi-subagents//` 内。 + +## 请求字段(MCP `pi_subagent_run`) + +外层:`worktree`(已登记任务工作区绝对路径)、`task_id`、`request` 对象。 + +`request` 必填: + +| 字段 | 约束 | +|---|---| +| `role_id` | 配置 §3 中 enabled 角色 | +| `request_id` | 任务内稳定唯一;同参重放不重复调模型 | +| `goal` | 具体目标;子任务不依赖主会话未传入历史 | +| `authorization_ref` | 可回链真实用户授权,禁止日志自授 | +| `source_sha` | **完整** 40-hex 提交;禁止 `HEAD`/分支名/短 hash | +| `input_files` | 已跟踪 UTF-8 普通文件相对路径数组 | +| `timeout_seconds` | 正整数 | + +测试角色(`test_run` / `acceptance_check`)另需: + +- `approved_commands`:argv 数组的数组(如 `[["go","test","-short","./"]]`) +- `plan_digest`:外部已批准计划的 SHA-256 hex + +禁止静默 `model_override`;临时改模型写任务 override 并说明用户来源。 + +## 租约与输出 + +- 资源:`path:.doc/test-reports/assets/pi-subagents//` +- 产物:`receipt.json`、`report.md`、命令日志(同目录) +- 裸调 `run`(无租约)被拒绝 + +## 状态分离(红线) + +| 字段 | 含义 | +|---|---| +| `run_status` | running / succeeded / failed / cancelled / unknown | +| `verdict` | pass / changes_required / blocked / not_applicable | +| `review_state` | 阶段门禁;模型 succeeded **仅** pending | + +测试 `commands_passed`/`pass` 只针对批准命令证据,不能直接赋给任务 `acceptance`。 + +## 常见拒绝码(执行器权威) + +| 码 | 处置 | +|---|---| +| `INVALID_SOURCE_SHA` | 换完整提交 hash | +| `APPROVED_COMMANDS_REQUIRED` | 补 approved_commands + plan_digest | +| `MINIMAX_ROLE_FORBIDDEN` | 不把 M3 用于关键角色 | +| `INPUT_NOT_TRACKED` / `PATH_OUT_OF_SCOPE` | 收窄 input_files | +| `CONCURRENCY_LIMIT` | 串行或等空闲槽 | +| `ORPHAN_RUNNING` | 先核进程/副作用,勿盲目重派 | + +Cursor hooks 对形状错误可能 `permission=ask`(advisory);**硬拒绝以执行器为准**。 + +## 禁止 + +- 把 implementation 派给 PI +- 自动换模型 / 伪造 lease / 递归把本修复再派给 PI worker +- 用模型叙事冒充 PASS;审查结论须带回报告路径与 receipt 字段 diff --git a/.cursor/skills/subagent-stage-record/SKILL.md b/.cursor/skills/subagent-stage-record/SKILL.md new file mode 100644 index 00000000..afe62f5a --- /dev/null +++ b/.cursor/skills/subagent-stage-record/SKILL.md @@ -0,0 +1,56 @@ +--- +name: subagent-stage-record +description: Record PI/Cursor stage handoffs using the subagent stage template (dispatch packet, return, S0–S9 receipt, R-AC-T mapping). Use after PI results, before gate advances, or when closing a multi-stage task. +--- + +# Subagent 阶段记录 + +权威模板:[subagent-stage-record.md](../../../.doc/ops/templates/subagent-stage-record.md);流程:[协作规范](../../../.doc/ops/2026-09-24-subagent-workflow-standard.md)。派发入口:[pi-subagent-dispatch](../pi-subagent-dispatch/SKILL.md)。 + +原则:**一步一记录,不必一步一文件**。小任务可把适用段落并入任务主文档;跨模块才拆独立审查/测试/验收证据。 + +## 何时写 + +- 每次 `pi_subagent_run` 前后:派发包 + 回传 +- 阶段门禁推进前:S0–S9 收据行 +- 收口前:R→AC→改动→审查→T→运行验收映射 + +## 派发包(最小字段) + +复制到任务文档并填实值: + +- `parent_task_id` / `subtask_id` / `stage_id` / `role_id` +- 配置路径 / `config_revision` / `config_sha256`(来自 roles 或 receipt) +- 解析后的 provider/model;任务 override 与用户来源 +- 目标、非目标、授权来源、交付终点 +- 输入版本:R-ID / AC-ID / 方案版本 / `base_sha` / `head_sha` 或 `source_sha` +- cwd / branch / allowed_paths / 只读或写入 / mutex +- 完成条件、输出路径、预算与异常处理 + +## 子任务回传 + +- PI `run_id` / producer / 起止时间 +- `configured_model` / `actual_model`(不可观察填 `unknown`) +- `run_status` 与 `verdict` **分列**(不得把退出成功当质量通过) +- 结论与依据、证据链接、脱敏范围 +- 未覆盖项、风险、遗留进程/产物、接管建议 + +只读子 Agent 结论由主 Agent 转存时:保留 `agent_id` 与原结论,注明「由主 Agent 转存」。 + +## 阶段收据表 + +| 阶段 | 输入版本 | 产物/证据 | producer | reviewer/独立性 | 决定 | 下一步 | +|---|---|---|---|---|---|---| +| S0–S9 适用项 | | | | independent / self_review / not_required | pass / changes_required / blocked / not_applicable | | + +未执行阶段写 `not_applicable` + 原因。报告存在不自动满足门禁。 + +## 需求追踪 + +| R-ID | AC-ID | 改动/版本 | 审查 | T-ID / 结果 | 运行验收 | acceptance_owner | +|---|---|---|---|---|---|---| +| | | | | | | | + +## 与任务五态 + +继续用任务模板的 `status`、`review_state`、`integration_state`、`deployment`、`cleanup`、`remote_sync`;额外记 `phase`、`acceptance`、待回收 `subtask_id`。`done` 取决于授权交付终点,不因会话结束自动完成。 diff --git a/.doc/audit/2026-09-24-cursor-hook-event-contract.md b/.doc/audit/2026-09-24-cursor-hook-event-contract.md new file mode 100644 index 00000000..a4861735 --- /dev/null +++ b/.doc/audit/2026-09-24-cursor-hook-event-contract.md @@ -0,0 +1,41 @@ +--- +issue_id: ISSUE-20260924-cursor-hook-event-contract +issue_state: open +severity: medium +owner: Cursor +task_ids: [cursor-governance-hooks-2026-09-24] +created: 2026-09-24 +occurred_at: 2026-09-24 +updated: 2026-09-24 +recurrence_count: 0 +evidence_basis: current +lesson_ids: [LESSON-20260924-cursor-hook-advisory-gates] +--- + +# Cursor hook 事件字段契约未文档化 + +## 事实与证据 + +预期:`beforeMCPExecution` / `sessionStart` / `stop` 的 stdin 字段名与 matcher 匹配串在官方文档中明确,便于 failClosed 硬门禁。 +实际:官方 create-hook skill 仅明确 `beforeShellExecution` 的 `command` 字段;`beforeMCPExecution` 不支持 `updated_input`;`sessionStart` 无文档化返回字段可注入上下文;`MCP: ...` matcher 形式只写给 `preToolUse` 族。实现层字符串探测存在 `mcp_server_name`/`tool_name`/`tool_input`,但非文档契约。 + +证据:本任务调研记录;单元测试用多名容错驱动;真实 Cursor IDE 事件采样待 `CURSOR_HOOKS_PROBE=1`。 + +## 根因与同类范围 + +客户端 hook API 文档滞后于实现。同类:任何依赖未文档化 hook 输入的 failClosed 门禁都有假阴性/假阳性风险。 + +## 修复思路与过程 + +| 日期 | 动作 | 结果 | 下一步 | +|---|---|---|---| +| 2026-09-24 | 落地 advisory hooks + 多名容错 + probe 开关;异形 fail-open | 单元回归 PASS | 用户触发真实 MCP/shell 事件采样后收紧 | + +## 验收与关闭 + +- 静态/单元:已覆盖(见测试报告) +- 实机:打开 `CURSOR_HOOKS_PROBE=1`,触发一次 `pi_subagent_roles` 与一次 shell,归档 `.cursor/hook-probe.jsonl` 片段到测试报告 assets;确认字段名后可将 matcher/`failClosed` 收紧并将本 issue → verifying/resolved + +## 经验与复发 + +见 [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md)。 diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 09ebc78c..4746c28f 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -2,7 +2,7 @@ 维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。 -更新:2026-09-24。登记管理员重置密码死功能、登录信封误导、无凭据僵尸账号;历史 resolved 不表示本日运行回归通过。 +更新:2026-09-24。Cursor 治理层:测试规则漂移/MCP 路径改为 verifying;新登记 hook 事件契约 issue;历史 resolved 不表示本日产品回归通过。 | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| @@ -13,6 +13,7 @@ | ISSUE-20260924-admin-reset-dead | auth / adminResetPassword / T20-F4 / chenwei | 管理台重置密码死按钮;验收口令不可知后无法恢复 | high | resolved | Cursor | 2026-09-24;test 库实查 + API 单测 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-admin-reset-dead) | 待补 LESSON | | ISSUE-20260924-login-error-envelope | auth / AUTHZ / 无权访问 / auth.ts | 登录失败展示裸 JSON / 「无权访问」误导 | medium | resolved | Cursor | 2026-09-24;wire 复现 + 前端单测 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-login-error-envelope) | 待补 LESSON | | ISSUE-20260924-zombie-credentials | auth / user_credentials / LEFT JOIN / CreateUser | 空哈希静默建号导致永久无法登录 | high | resolved | Cursor | 2026-09-24;三环境只读盘点;机制修复不含存量清理 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-zombie-credentials) | 待补 LESSON | -| ISSUE-20260924-cursor-test-rule-drift | Cursor / test-reports / 固定模型 / 规则副本 | Cursor 测试规则与验证契约存在固定模型及范围冲突 | medium | open | Codex(研究) | 2026-09-24;静态规则比对,客户端行为未复现 | [主记录](2026-09-24-subagent-workflow-research.md#4-issue-20260924-cursor-test-rule-drift) | 未修复验证,不新增 validated 经验 | +| ISSUE-20260924-cursor-test-rule-drift | Cursor / test-reports / 固定模型 / 规则副本 | Cursor 测试规则与验证契约存在固定模型及范围冲突 | medium | verifying | Cursor | 2026-09-24;规则文本已缩为验证契约指针;客户端实际读取行为待实机 | [主记录](2026-09-24-subagent-workflow-research.md#4-issue-20260924-cursor-test-rule-drift) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | | ISSUE-20260924-pi-executor-boundaries | PI executor / 路径规范化 / MCP / 子进程 / 幂等 | 未发布初版只读边界与执行可靠性缺陷 | high | resolved | Cursor/Codex | 2026-09-24;173 工程回归与真实 Cursor CLI/MCP/PI | [主记录](2026-09-24-pi-executor-boundaries.md) | [工具路径与输入隔离](../ops/lessons/2026-09-24-pi-tool-boundaries.md) | -| ISSUE-20260924-cursor-cli-mcp-path | Cursor CLI / MCP / workspaceFolder | 既有 agentctl/jev 项目 MCP 入口在本机 CLI 连接失败 | medium | deferred | 工程维护 | 2026-09-24;CLI list/list-tools,PI 入口已修复 | [主记录](2026-09-24-pi-executor-boundaries.md#cursor-cli-mcp-path) | 沿用工具路径经验 | +| ISSUE-20260924-cursor-cli-mcp-path | Cursor CLI / MCP / workspaceFolder | 既有 agentctl/jev 项目 MCP 入口在本机 CLI 连接失败 | medium | verifying | Cursor | 2026-09-24;mcp.json 已改相对路径;IDE/CLI list 证据待补 | [主记录](2026-09-24-pi-executor-boundaries.md#cursor-cli-mcp-path) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | +| ISSUE-20260924-cursor-hook-event-contract | Cursor / hooks / beforeMCPExecution / probe | hook 事件 stdin 字段名与 matcher 官方未文档化 | medium | open | Cursor | 2026-09-24;单元容错已落地;实机 probe 待采样 | [主记录](2026-09-24-cursor-hook-event-contract.md) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | diff --git a/.doc/ops/2026-09-21-policy-registry.md b/.doc/ops/2026-09-21-policy-registry.md index 1b23713a..4e7bc683 100644 --- a/.doc/ops/2026-09-21-policy-registry.md +++ b/.doc/ops/2026-09-21-policy-registry.md @@ -51,3 +51,12 @@ CURRENT 是带时间的活跃协调快照;Git/机器状态用于核验实际 修订 ID:`subagent-workflow-2026-09-24-r2`;owner:Codex;state:`proposed`;能力:`manual`;effective_commit:无。 用户要求研究 Cursor Auto 下的 subagent 分工、阶段流程及报告,授权与本次验收见[任务](short-term/2026-09-24-subagent-workflow-standard.md)。[规范草案](2026-09-24-subagent-workflow-standard.md)为拟议行为的唯一来源,[研究报告](../audit/2026-09-24-subagent-workflow-research.md)给出事实和待修冲突,[模板](templates/subagent-stage-record.md)承载交接。 用户后续明确采用 Cursor Auto 实施 + gmktec 本机 PI 子任务,并批准默认模型分工及可调整文档。[模型配置](2026-09-24-subagent-model-config.md)为模型路由唯一来源,decision_state=approved;流程整体仍 proposed;已批准的模型配置与执行器已合入本地 dev。原 Cursor 原生角色/inherit 方案被替代。执行器已在任务分支实现并验证 Cursor CLI→MCP→PI,代码 69ffaa677e23a1dd9aa0528bb09b3acfcd38b119;[运行说明](2026-09-24-pi-subagent-executor.md)与[验证报告](../test-reports/2026-09-24-pi-executor-test-report.md)限定已验证的机器边界。dev 集成/完整流程审批仍独立记录;不以派发器可用宣称全阶段自动门禁。 + +## Cursor 治理层局部修订 + +修订 ID:`cursor-governance-2026-09-24-r1`;owner:`Cursor`;state:合入 local_dev 后 `active`(effective_commit 以集成收据为准);工具能力分层: +- 规则/skill 文本:`manual`(Agent 读取执行) +- hook 脚本 stdin→stdout 行为:`enforced`(`scripts/tests/test_cursor_hooks.py`) +- hook ↔ Cursor IDE 接线:`advisory`/`manual`(事件字段待 probe 采样;见 ISSUE-20260924-cursor-hook-event-contract) + +用户授权:在 PI 执行器合入后补齐 skills/rules/hooks;范围与验收见[任务](short-term/2026-09-24-cursor-governance-hooks.md)。客户端入口:`.cursor/rules/cursor-hooks.mdc`、`pi-subagents.mdc`、`test-reports-standard.mdc`(缩为验证契约指针);skills `pi-subagent-dispatch` / `subagent-stage-record`;hooks 见 `.cursor/hooks.json`。不改变产品 Runner、不扩大执行器硬门禁语义。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 11ba6a99..efa3093e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | cursor-governance-hooks-2026-09-24 | feature/cursor-governance-hooks-2026-09-24 | .worktrees/cursor-governance-hooks-2026-09-24 | cursor-governance | in_progress | | Codex | subagent-workflow-standard-2026-09-24-land | feature/subagent-workflow-standard-2026-09-24 | .worktrees/subagent-workflow-standard-2026-09-24 | pi-subagent-integration | done | | Cursor/Codex | subagent-workflow-standard-2026-09-24-executor | feature/subagent-workflow-standard-2026-09-24 | .worktrees/subagent-workflow-standard-2026-09-24 | pi-subagent-executor | done | | Codex | subagent-workflow-standard-2026-09-24-config-r2 | feature/subagent-workflow-standard-2026-09-24 | .worktrees/subagent-workflow-standard-2026-09-24 | subagent-workflow-docs | done | diff --git a/.doc/ops/lessons/2026-09-24-cursor-hook-advisory-gates.md b/.doc/ops/lessons/2026-09-24-cursor-hook-advisory-gates.md new file mode 100644 index 00000000..202ef5a6 --- /dev/null +++ b/.doc/ops/lessons/2026-09-24-cursor-hook-advisory-gates.md @@ -0,0 +1,28 @@ +--- +lesson_id: LESSON-20260924-cursor-hook-advisory-gates +lesson_state: candidate +owner: Cursor +reviewer: Cursor(自审;实机采样后转 validated) +last_reviewed: 2026-09-24 +reviewed_revision: cursor-governance-hooks-2026-09-24 worktree +source_issues: + - ISSUE-20260924-cursor-hook-event-contract + - ISSUE-20260924-cursor-test-rule-drift + - ISSUE-20260924-cursor-cli-mcp-path +keywords: [Cursor, hooks, advisory, fail-open, probe, stdin, MCP] +--- + +# 客户端 hook 与执行器门禁分层;契约未知时 fail-open + +- 触发条件:为 Cursor 增加 hooks/rules,或发现 hook「写了但不生效/误拦」时。 +- 机制与来源:执行器(`pi_subagents.py` / agentctl)已有权威硬拒绝;hook 事件字段官方未完全文档化。heredoc 包装 Python 会吞掉 hook stdin。审计日志若放进被 git 跟踪的 `.cursor/hooks/` 会污染仓库。 +- 开发检查: + 1. hook 只做形状/授权类 `ask`,不复刻执行器全部校验。 + 2. 用独立 `.py` + 薄 `.sh` `exec`,禁止 heredoc 占 stdin。 + 3. 多名容错(`tool_name|mcp_tool_name|name` 等);识别失败 → allow + `UNRECOGNIZED_INPUT` 审计。 + 4. 审计/probe 写 `.cursor/hook-audit.jsonl` / `hook-probe.jsonl`(被 `.cursor/*` ignore)。 + 5. 规则副本只做指针,不复制验证契约中的模型名与全量命令。 +- 验证方式:`scripts/tests/test_cursor_hooks.py`;实机 `CURSOR_HOOKS_PROBE=1` 采样后升 validated。 +- 适用边界:本仓库项目级 hooks;不声称覆盖所有 Cursor 版本或 CLI 加载行为。 +- 防护落实:单元回归 enforced;hook↔Cursor 接线仍 advisory/manual。 +- 修订与失效:实机字段确认后收紧 matcher 并更新本条状态。 diff --git a/.doc/ops/lessons/INDEX.md b/.doc/ops/lessons/INDEX.md index f59e3b52..908c64e1 100644 --- a/.doc/ops/lessons/INDEX.md +++ b/.doc/ops/lessons/INDEX.md @@ -10,3 +10,4 @@ validated 的证据范围见各条目;本日复核为历史材料和源码阅读,未重跑产品回归。后续按机制补充,过时条目标 superseded/retired 并保留替代链接。 | LESSON-20260924-pi-tool-boundaries | PI、MCP、readonly、path、snapshot、process-group | 路径语义与快照边界一致,合法路径和拒绝路径都验收 | validated | [文件工具边界](2026-09-24-pi-tool-boundaries.md);2026-09-24 | +| LESSON-20260924-cursor-hook-advisory-gates | Cursor、hooks、advisory、fail-open、probe、stdin | hook 与执行器分层;契约未知 fail-open;审计日志放 ignore 路径 | candidate | [客户端 hook 门禁](2026-09-24-cursor-hook-advisory-gates.md);2026-09-24 | diff --git a/.doc/ops/short-term/2026-09-24-cursor-governance-hooks.md b/.doc/ops/short-term/2026-09-24-cursor-governance-hooks.md new file mode 100644 index 00000000..20711556 --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-cursor-governance-hooks.md @@ -0,0 +1,80 @@ +--- +title: "Cursor 治理层扩展(rules / skills / hooks)" +task_id: cursor-governance-hooks-2026-09-24 +status: in_progress +created: 2026-09-24 +updated: 2026-09-24 +owner: Cursor +task_type: implementation +task_tier: multi_stage +policy_revision: "agent-governance-2026-09-21-r1 + issue-learning-2026-09-23-r1 + codegraph-governance-2026-09-24-r1 + subagent-workflow-2026-09-24-r2(proposed); base_dev@cfc80b34a40e8de78e77a44839571bb684d405d5" +branch: feature/cursor-governance-hooks-2026-09-24 +worktree: .worktrees/cursor-governance-hooks-2026-09-24 +base_dev_sha: cfc80b34a40e8de78e77a44839571bb684d405d5 +write_scope: [".cursor", "scripts/tests", ".doc"] +allowed_environments: [] +mutex: cursor-governance +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: "owner 退出后的独立维护者" +cleanup_trigger: "合入 local_dev、证据归档且 owner 退出后复查" +remote_sync: not_requested +--- + +# Cursor 治理层扩展(rules / skills / hooks) + +## 用户目标与授权 + +- 用户目标:在已合入的 PI subagent 执行器基础上,为本项目 Cursor 补齐 skills、rules、hooks 等治理层。 +- 授权来源:本会话确认计划 `cursor_governance_layer`,范围含 7 项全选、hook 命中一律 `ask`、session+postToolUse 双轨、字段识别 both_phases。 +- 交付终点:任务分支实现并通过工程回归;受控合入 local `dev`;不 push;不重建产品服务。 +- 禁止:未授权 push / 晋升 test/main;改产品 Runner;以 stub 冒充产品 PASS;覆盖他人 CURRENT/MATRIX。 + +## 范围与非目标 + +- 允许:`.cursor/rules|skills|hooks|mcp.json`、`scripts/tests/test_cursor_hooks.py`、相关 `.doc`(任务/台账/经验/规范登记/测试报告)。 +- 非目标:改 `scripts/pi_subagents.py` 硬门禁语义;实现 S0–S9 自动状态机;产品 Go/前端/PG。 + +## 相关问题与经验 + +- ISSUE-20260924-cursor-test-rule-drift(open → verifying) +- ISSUE-20260924-cursor-cli-mcp-path(deferred → verifying) +- 新建 ISSUE-20260924-cursor-hook-event-contract(open) +- 采纳 LESSON-20260923-matrix-status-cas、LESSON-20260924-pi-tool-boundaries;新建 LESSON-20260924-cursor-hook-advisory-gates(candidate) + +## 需求与验收 + +| R-ID | 要求 | AC-ID | +|---|---|---| +| R1 | 修复测试规则漂移 | AC1:无固定模型/无条件全量 Go/失效 §8;指向验证契约 | +| R2 | PI 派发 skill | AC2:roles→run→result、租约、拒绝码、run_status≠verdict | +| R3 | 阶段记录 skill | AC3:派发包/回传/收据/R-AC-T 可执行 | +| R4 | PI/会话/收据 hooks | AC4:ask;fail-open 异形;probe 开关;单元可测 | +| R5 | MCP 路径 | AC5:agentctl/jev 相对路径;CLI 证据或 verifying | +| R6 | rules 收敛与登记 | AC6:薄指针 + policy-registry 修订 | +| R7 | 工程回归 | AC7:unittest discover 全绿;文档静态检查;报告归档 | + +## 步骤与验收 + +1. [ ] 建场与认领 +2. [ ] 规则漂移修复 +3. [ ] 两 skill +4. [ ] 四 hook + hooks.json +5. [ ] MCP 路径 +6. [ ] rules 收敛 +7. [ ] 测试与全量回归 +8. [ ] ISSUES/lesson/policy-registry +9. [ ] 测试报告 +10. [ ] probe 独立验收(需用户配合;未采样前 advisory) +11. [ ] 受控合入 local_dev + +## 验证与证据 + +见后续测试报告与本记录更新。产品链路 NOT REQUIRED。 + +## 相关链接 + +- 计划:用户确认的 Cursor governance layer +- 权威:`.doc/ops/2026-09-24-subagent-model-config.md` · `2026-09-24-subagent-workflow-standard.md` · `2026-09-24-pi-subagent-executor.md` · `2026-09-21-validation-contract.md` diff --git a/.doc/test-reports/2026-09-24-cursor-governance-hooks-test-report.md b/.doc/test-reports/2026-09-24-cursor-governance-hooks-test-report.md new file mode 100644 index 00000000..f119178d --- /dev/null +++ b/.doc/test-reports/2026-09-24-cursor-governance-hooks-test-report.md @@ -0,0 +1,79 @@ +# Cursor 治理层扩展(rules / skills / hooks)测试报告 + +## 一、 测试背景 (Background) + +PI subagent 执行器已合入 local `dev`(`cfc80b34`)。本任务补齐 Cursor 客户端治理层:修复 `test-reports-standard.mdc` 规则漂移、新增派发/阶段记录 skills、新增 advisory hooks、修正 agentctl/jev MCP 相对路径,并将能力分层登记进规范登记表。 + +## 二、 测试目标 (Test Objectives) + +| AC | 目标 | 判定 | +|---|---|---| +| AC1 | 规则无硬编码模型/无条件全量 Go/失效 §8 | PASS(静态 grep) | +| AC2–AC3 | 两 skill 存在且指向权威文档 | PASS | +| AC4 | hooks ask + fail-open + probe;单元可测 | PASS(29 项) | +| AC5 | mcp.json agentctl/jev 相对路径 | PASS(结构);CLI 显示 registered/needs approval(未宣称 Connection 已绿) | +| AC6 | rules 收敛 + policy-registry 修订 | PASS | +| AC7 | unittest discover 全绿 | PASS(202) | + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Python 3.14;工作区 `.worktrees/cursor-governance-hooks-2026-09-24` +- 基线:`cfc80b34a40e8de78e77a44839571bb684d405d5` +- 层级:工程脚本单元 + 文档静态;**产品 Go/前端/PG/真实产品 Runner:NOT REQUIRED**(无产品代码变更) + +## 四、 预期结果 (Expected Results) + +- hook 合法放行;形状错误/裸跑/`pkill -f` → `permission=ask` +- 异形 JSON → allow + `UNRECOGNIZED_INPUT` +- 全量 `scripts/tests` 无回归 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Cursor hooks 专项 + +命令:`python3 -B -m unittest scripts.tests.test_cursor_hooks -v` + +- 29 tests OK(结构、guard-git、guard-pi-dispatch、guard-pi-shell、receipt-context、audit-session、skills) +- 证据:[unittest-discover.txt](assets/2026-09-24-cursor-governance-hooks/unittest-discover.txt) + +### 5.2 全量工程回归 + +命令:`python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' -v` + +- **Ran 202 tests in 111.351s — OK**(原 173 + 新增 29) +- 判定:PASS + +### 5.3 文档/规则静态 + +- `grep` rules:无 `MiniMax-M2.7-highspeed`、无 `PROJECT.md) §8`、无 `go test -v ./...` → PASS +- `git diff --check`:PASS +- `agentctl docs drift`:`status=ok`,`regressions=[]` + +### 5.4 MCP 路径 + +- `.cursor/mcp.json` 中 agentctl/jev/pi-subagents 均使用 `scripts/mcp/...` 相对路径 +- CLI `agent mcp list`:四服务均显示 `not loaded (needs approval)`(已注册;加载需用户在客户端批准)。**不宣称已消除 Connection failed**;issue 保持 verifying + +### 5.5 IDE probe(独立验收) + +状态:`pending_ide_sample` — 见 [probe-status.json](assets/2026-09-24-cursor-governance-hooks/probe-status.json) + +用户步骤: + +1. 重载项目 MCP / Hooks +2. `export CURSOR_HOOKS_PROBE=1`(或在 Cursor 环境中设置) +3. 触发一次 `pi_subagent_roles` 与任意 shell +4. 将 `.cursor/hook-probe.jsonl` 脱敏片段归档到本 assets,再收紧 matcher + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| `unittest discover scripts/tests` | 202 OK | +| 产品 PI / Go 全量 | NOT REQUIRED | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到授权交付终点(工程治理层合入 local_dev)。后续:IDE probe 采样后关闭/推进 hook 契约 issue;CLI 批准 MCP 后复核 agentctl/jev;lesson 由 candidate → validated。 + +相关:任务 [2026-09-24-cursor-governance-hooks](../ops/short-term/2026-09-24-cursor-governance-hooks.md);ISSUE hook 契约 / 规则漂移 / MCP 路径。 diff --git a/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/probe-status.json b/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/probe-status.json new file mode 100644 index 00000000..59719abc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/probe-status.json @@ -0,0 +1,22 @@ +{ + "status": "pending_ide_sample", + "how_to": "CURSOR_HOOKS_PROBE=1 then trigger pi_subagent_roles and any shell; collect .cursor/hook-probe.jsonl", + "unit_samples": [ + { + "event": "beforeShellExecution", + "synthetic_payload_keys": [ + "command" + ], + "note": "unit-driven; IDE probe pending" + }, + { + "event": "beforeMCPExecution", + "synthetic_payload_keys": [ + "mcp_server_name", + "tool_name", + "tool_input" + ], + "note": "unit-driven; IDE probe pending" + } + ] +} diff --git a/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/unittest-discover.txt b/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/unittest-discover.txt new file mode 100644 index 00000000..5d0c500e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-cursor-governance-hooks/unittest-discover.txt @@ -0,0 +1,279 @@ +test_linux_permission_error_path_remains_fail_closed (test_agent_worktree.ProcessVisibilityTest.test_linux_permission_error_path_remains_fail_closed) ... ok +test_unknown_same_uid_inaccessible_cwd_is_not_skipped (test_agent_worktree.ProcessVisibilityTest.test_unknown_same_uid_inaccessible_cwd_is_not_skipped) ... ok +test_branch_delete_refusal_preserves_branch_without_force_with_visibility_fixture (test_agent_worktree.WorktreeTest.test_branch_delete_refusal_preserves_branch_without_force_with_visibility_fixture) ... ok +test_cleanup_actual_branch_mapping_with_visibility_fixture (test_agent_worktree.WorktreeTest.test_cleanup_actual_branch_mapping_with_visibility_fixture) ... ok +test_cleanup_legacy_mapping_and_nonforced_branch_delete_with_visibility_fixture (test_agent_worktree.WorktreeTest.test_cleanup_legacy_mapping_and_nonforced_branch_delete_with_visibility_fixture) ... ok +test_cleanup_lifecycle_lock_prevents_a_new_run (test_agent_worktree.WorktreeTest.test_cleanup_lifecycle_lock_prevents_a_new_run) ... ok +test_cleanup_refuses_active_registry_operation_even_with_other_cwd (test_agent_worktree.WorktreeTest.test_cleanup_refuses_active_registry_operation_even_with_other_cwd) ... ok +test_cleanup_refuses_live_process_cwd (test_agent_worktree.WorktreeTest.test_cleanup_refuses_live_process_cwd) ... ok +test_cleanup_refuses_tracked_untracked_and_ignored_evidence (test_agent_worktree.WorktreeTest.test_cleanup_refuses_tracked_untracked_and_ignored_evidence) ... ok +test_cleanup_refuses_unfinished_git_operation (test_agent_worktree.WorktreeTest.test_cleanup_refuses_unfinished_git_operation) ... ok +test_cleanup_refuses_unmerged_commit (test_agent_worktree.WorktreeTest.test_cleanup_refuses_unmerged_commit) ... ok +test_create_from_feature_uses_current_dev_and_canonical_main (test_agent_worktree.WorktreeTest.test_create_from_feature_uses_current_dev_and_canonical_main) ... ok +test_create_with_main_on_unrelated_branch_uses_dev (test_agent_worktree.WorktreeTest.test_create_with_main_on_unrelated_branch_uses_dev) ... ok +test_forged_inherited_lease_is_refused (test_agent_worktree.WorktreeTest.test_forged_inherited_lease_is_refused) ... ok +test_inherited_worktree_lease_does_not_double_lock (test_agent_worktree.WorktreeTest.test_inherited_worktree_lease_does_not_double_lock) ... ok +test_list_is_read_only_without_init (test_agent_worktree.WorktreeTest.test_list_is_read_only_without_init) ... ok +test_rejects_traversal_nested_and_protected_branches (test_agent_worktree.WorktreeTest.test_rejects_traversal_nested_and_protected_branches) ... ok +test_same_task_id_and_existing_branch_do_not_collide (test_agent_worktree.WorktreeTest.test_same_task_id_and_existing_branch_do_not_collide) ... ok +test_stable_lock_refuses_concurrent_writer (test_agent_worktree.WorktreeTest.test_stable_lock_refuses_concurrent_writer) ... ok +test_symlink_parent_or_target_is_refused (test_agent_worktree.WorktreeTest.test_symlink_parent_or_target_is_refused) ... ok +test_write_requires_explicit_init (test_agent_worktree.WorktreeTest.test_write_requires_explicit_init) ... ok +test_admin_document_cannot_bypass_done_with_compact_cells (test_agentctl.AgentctlTests.test_admin_document_cannot_bypass_done_with_compact_cells) ... ok +test_admin_document_cannot_overwrite_human_sections (test_agentctl.AgentctlTests.test_admin_document_cannot_overwrite_human_sections) ... ok +test_admin_document_cannot_overwrite_integration_slot (test_agentctl.AgentctlTests.test_admin_document_cannot_overwrite_integration_slot) ... ok +test_admin_document_cas_preserves_slot_and_needs_capability (test_agentctl.AgentctlTests.test_admin_document_cas_preserves_slot_and_needs_capability) ... ok +test_admin_full_document_requires_explicit_admin_capability (test_agentctl.AgentctlTests.test_admin_full_document_requires_explicit_admin_capability) ... ok +test_claim_blocks_task_close_until_released (test_agentctl.AgentctlTests.test_claim_blocks_task_close_until_released) ... ok +test_claim_concurrent_twenty_exactly_one_held (test_agentctl.AgentctlTests.test_claim_concurrent_twenty_exactly_one_held) ... ok +test_claim_frozen_rejects_even_holder (test_agentctl.AgentctlTests.test_claim_frozen_rejects_even_holder) ... ok +test_claim_parent_child_overlap_and_sibling_ok (test_agentctl.AgentctlTests.test_claim_parent_child_overlap_and_sibling_ok) ... ok +test_claim_request_id_idempotent_and_reuse_guard (test_agentctl.AgentctlTests.test_claim_request_id_idempotent_and_reuse_guard) ... ok +test_claim_unrelated_resources_parallel_ok (test_agentctl.AgentctlTests.test_claim_unrelated_resources_parallel_ok) ... ok +test_commit_lease_must_cover_staged_files_not_only_task_scope (test_agentctl.AgentctlTests.test_commit_lease_must_cover_staged_files_not_only_task_scope) ... ok +test_daemon_does_not_grant_register_or_docs_apply (test_agentctl.AgentctlTests.test_daemon_does_not_grant_register_or_docs_apply) ... ok +test_daemon_single_instance_lock_busy_with_holder_pid (test_agentctl.AgentctlTests.test_daemon_single_instance_lock_busy_with_holder_pid) ... ok +test_daemon_stop_leaves_pending_queue_untouched (test_agentctl.AgentctlTests.test_daemon_stop_leaves_pending_queue_untouched) ... ok +test_descendant_resource_guard_accepts_only_held_resource (test_agentctl.AgentctlTests.test_descendant_resource_guard_accepts_only_held_resource) ... ok +test_docs_cannot_launder_unmanaged_other_task_changes (test_agentctl.AgentctlTests.test_docs_cannot_launder_unmanaged_other_task_changes) ... ok +test_docs_foreign_current_row_is_rejected (test_agentctl.AgentctlTests.test_docs_foreign_current_row_is_rejected) ... ok +test_docs_multi_file_preflight_failure_has_no_partial_writes (test_agentctl.AgentctlTests.test_docs_multi_file_preflight_failure_has_no_partial_writes) ... ok +test_docs_project_parallel_apply_keeps_both_rows (test_agentctl.AgentctlTests.test_docs_project_parallel_apply_keeps_both_rows) ... ok +test_docs_project_preview_apply_receipts_and_projections (test_agentctl.AgentctlTests.test_docs_project_preview_apply_receipts_and_projections) ... ok +test_docs_project_same_row_race_base_changed_no_partial (test_agentctl.AgentctlTests.test_docs_project_same_row_race_base_changed_no_partial) ... ok +test_docs_row_cas_preserves_unrelated_rows_and_is_idempotent (test_agentctl.AgentctlTests.test_docs_row_cas_preserves_unrelated_rows_and_is_idempotent) ... ok +test_env_guard_accepts_clean_main_and_requires_all_target_leases (test_agentctl.AgentctlTests.test_env_guard_accepts_clean_main_and_requires_all_target_leases) ... ok +test_env_guard_refuses_linked_worktree_even_with_capability (test_agentctl.AgentctlTests.test_env_guard_refuses_linked_worktree_even_with_capability) ... ok +test_failed_child_releases_lease_and_preserves_failure (test_agentctl.AgentctlTests.test_failed_child_releases_lease_and_preserves_failure) ... ok +test_failed_revalidation_revokes_old_success_receipt (test_agentctl.AgentctlTests.test_failed_revalidation_revokes_old_success_receipt) ... ok +test_guard_build_requires_exact_clean_commit (test_agentctl.AgentctlTests.test_guard_build_requires_exact_clean_commit) ... ok +test_hook_checks_reject_out_of_scope_and_secret_paths (test_agentctl.AgentctlTests.test_hook_checks_reject_out_of_scope_and_secret_paths) ... ok +test_hook_rejects_shared_document_without_publish_receipt (test_agentctl.AgentctlTests.test_hook_rejects_shared_document_without_publish_receipt) ... ok +test_hooks_allow_published_shared_document_receipt (test_agentctl.AgentctlTests.test_hooks_allow_published_shared_document_receipt) ... ok +test_hooks_allow_scoped_task_commit_under_live_lease (test_agentctl.AgentctlTests.test_hooks_allow_scoped_task_commit_under_live_lease) ... ok +test_hooks_refuse_overwriting_foreign_hook (test_agentctl.AgentctlTests.test_hooks_refuse_overwriting_foreign_hook) ... ok +test_init_cannot_steal_live_legacy_lock (test_agentctl.AgentctlTests.test_init_cannot_steal_live_legacy_lock) ... ok +test_init_preserves_existing_legacy_lock_inode (test_agentctl.AgentctlTests.test_init_preserves_existing_legacy_lock_inode) ... ok +test_installed_hooks_reject_direct_dev_commit (test_agentctl.AgentctlTests.test_installed_hooks_reject_direct_dev_commit) ... ok +test_interrupted_validation_stops_child_before_releasing_scope (test_agentctl.AgentctlTests.test_interrupted_validation_stops_child_before_releasing_scope) ... ok +test_invalid_document_request_shape_has_no_write_side_effects (test_agentctl.AgentctlTests.test_invalid_document_request_shape_has_no_write_side_effects) ... ok +test_linked_worktree_uses_only_main_coordination_root (test_agentctl.AgentctlTests.test_linked_worktree_uses_only_main_coordination_root) ... ok +test_live_and_stale_credentials_cannot_be_spoofed (test_agentctl.AgentctlTests.test_live_and_stale_credentials_cannot_be_spoofed) ... ok +test_live_run_cannot_be_recovered_or_task_closed (test_agentctl.AgentctlTests.test_live_run_cannot_be_recovered_or_task_closed) ... ok +test_matrix_done_without_evidence_refused (test_agentctl.AgentctlTests.test_matrix_done_without_evidence_refused) ... ok +test_matrix_duplicate_or_rename_record_id_rejected (test_agentctl.AgentctlTests.test_matrix_duplicate_or_rename_record_id_rejected) ... ok +test_matrix_record_id_layout_migration_without_new_dones (test_agentctl.AgentctlTests.test_matrix_record_id_layout_migration_without_new_dones) +Additive record_id column must not trip EVIDENCE_REQUIRED when statuses unchanged. ... ok +test_matrix_unknown_columns_and_lineage_preserved (test_agentctl.AgentctlTests.test_matrix_unknown_columns_and_lineage_preserved) ... ok +test_multi_resource_acquisition_is_atomic (test_agentctl.AgentctlTests.test_multi_resource_acquisition_is_atomic) ... ok +test_parent_child_path_conflict_and_disjoint_coexistence (test_agentctl.AgentctlTests.test_parent_child_path_conflict_and_disjoint_coexistence) ... ok +test_path_scope_and_capability_are_enforced (test_agentctl.AgentctlTests.test_path_scope_and_capability_are_enforced) ... ok +test_policy_invalid_json_rejects_write (test_agentctl.AgentctlTests.test_policy_invalid_json_rejects_write) ... ok +test_policy_missing_rejects_write_but_status_stays_readonly (test_agentctl.AgentctlTests.test_policy_missing_rejects_write_but_status_stays_readonly) ... ok +test_policy_protocol_too_low_rejects_write (test_agentctl.AgentctlTests.test_policy_protocol_too_low_rejects_write) ... ok +test_quarantine_sweep_keeps_live_child_and_release_refuses (test_agentctl.AgentctlTests.test_quarantine_sweep_keeps_live_child_and_release_refuses) ... ok +test_quarantine_ttl_demotes_never_auto_completes (test_agentctl.AgentctlTests.test_quarantine_ttl_demotes_never_auto_completes) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/email/errors.py:12: ResourceWarning: unclosed database in + class MessageParseError(MessageError): +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/email/errors.py:12: ResourceWarning: unclosed database in + class MessageParseError(MessageError): +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/email/errors.py:12: ResourceWarning: unclosed database in + class MessageParseError(MessageError): +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/email/errors.py:12: ResourceWarning: unclosed database in + class MessageParseError(MessageError): +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/email/errors.py:12: ResourceWarning: unclosed database in + class MessageParseError(MessageError): +ResourceWarning: Enable tracemalloc to get the object allocation traceback +{"demoted": [{"from": "running", "run_id": "hb-stale", "to": "suspect"}, {"from": "suspect", "run_id": "sus-stale", "to": "quarantine"}], "heartbeat_ttl_seconds": 300, "status": "ok", "suspect_ttl_seconds": 600} +ok +test_quarantine_unreadable_proc_stops_at_suspect (test_agentctl.AgentctlTests.test_quarantine_unreadable_proc_stops_at_suspect) ... {"demoted": [{"from": "running", "run_id": "run-unknown", "to": "suspect"}], "heartbeat_ttl_seconds": 300, "status": "ok", "suspect_ttl_seconds": 600} +ok +test_queue_enqueue_list_cancel_roundtrip (test_agentctl.AgentctlTests.test_queue_enqueue_list_cancel_roundtrip) ... ok +test_queue_schedule_promotes_validated_candidate (test_agentctl.AgentctlTests.test_queue_schedule_promotes_validated_candidate) ... ok +test_queue_subparser_registered_once (test_agentctl.AgentctlTests.test_queue_subparser_registered_once) +Duplicate queue registration must not shadow handler-compatible flags. ... usage: python3.14 -m unittest [-h] [--repo REPO] + {init,status,doctor,task,with,guard,worktree-check,docs,queue,cleanup,recover,operation,freeze,unfreeze,hooks,validate,integrate,deploy,claim,quarantine,daemon} ... +python3.14 -m unittest: error: unrecognized arguments: --head-sha deadbeef +ok +test_recover_git_dev_requires_verification_marker (test_agentctl.AgentctlTests.test_recover_git_dev_requires_verification_marker) ... ok +test_recovery_refuses_dead_supervisor_with_live_child (test_agentctl.AgentctlTests.test_recovery_refuses_dead_supervisor_with_live_child) ... ok +test_registered_active_task_must_close_before_cleanup (test_agentctl.AgentctlTests.test_registered_active_task_must_close_before_cleanup) ... ok +test_replaced_compatibility_inode_is_not_a_new_free_lock (test_agentctl.AgentctlTests.test_replaced_compatibility_inode_is_not_a_new_free_lock) ... ok +test_shared_lease_does_not_satisfy_write_guard (test_agentctl.AgentctlTests.test_shared_lease_does_not_satisfy_write_guard) ... ok +test_stale_fence_env_is_stale_generation (test_agentctl.AgentctlTests.test_stale_fence_env_is_stale_generation) ... ok +test_task_close_does_not_auto_promote_matrix_done (test_agentctl.AgentctlTests.test_task_close_does_not_auto_promote_matrix_done) ... ok +test_task_index_lease_serializes_index_but_not_disjoint_work (test_agentctl.AgentctlTests.test_task_index_lease_serializes_index_but_not_disjoint_work) ... ok +test_task_registration_rejects_protected_branch_and_invalid_scopes (test_agentctl.AgentctlTests.test_task_registration_rejects_protected_branch_and_invalid_scopes) ... ok +test_uninitialized_status_and_doctor_do_not_create_state (test_agentctl.AgentctlTests.test_uninitialized_status_and_doctor_do_not_create_state) ... ok +test_unset_fence_env_matches_default_zero (test_agentctl.AgentctlTests.test_unset_fence_env_matches_default_zero) +Self-bootstrap: hooks re-exec updated agentctl without AGENTCTL_FENCE set. ... ok +test_validated_linear_commit_cannot_bypass_candidate_entrypoint (test_agentctl.AgentctlTests.test_validated_linear_commit_cannot_bypass_candidate_entrypoint) ... ok +test_validated_merge_candidate_is_exactly_promoted_to_dev (test_agentctl.AgentctlTests.test_validated_merge_candidate_is_exactly_promoted_to_dev) ... ok +test_boot_and_self_proc_are_observable (test_agentctl.ProcessPlatformTests.test_boot_and_self_proc_are_observable) ... ok +test_descent_unreadable_is_unknown (test_agentctl.ProcessPlatformTests.test_descent_unreadable_is_unknown) ... ok +test_gone_pid_is_dead (test_agentctl.ProcessPlatformTests.test_gone_pid_is_dead) ... ok +test_lease_denies_when_supervisor_proc_unreadable (test_agentctl.ProcessPlatformTests.test_lease_denies_when_supervisor_proc_unreadable) ... ok +test_unreadable_proc_is_unknown_not_dead (test_agentctl.ProcessPlatformTests.test_unreadable_proc_is_unknown_not_dead) ... ok +test_zombie_is_not_alive (test_agentctl.ProcessPlatformTests.test_zombie_is_not_alive) ... ok +test_build_projection_row_canonical (test_agentctl_w4.AgentctlW4Tests.test_build_projection_row_canonical) ... ok +test_deploy_recover_loop_failure_sets_recovery_required (test_agentctl_w4.AgentctlW4Tests.test_deploy_recover_loop_failure_sets_recovery_required) ... ok +test_deploy_recover_loop_success_with_fake_command (test_agentctl_w4.AgentctlW4Tests.test_deploy_recover_loop_success_with_fake_command) ... ok +test_document_projection_cas_mismatch_refuses (test_agentctl_w4.AgentctlW4Tests.test_document_projection_cas_mismatch_refuses) ... ok +test_project_cleanup_markdown_header_is_report_only (test_agentctl_w4.AgentctlW4Tests.test_project_cleanup_markdown_header_is_report_only) ... ok +test_recover_deploy_from_recovery_required (test_agentctl_w4.AgentctlW4Tests.test_recover_deploy_from_recovery_required) ... ok +test_row_projection_ambiguous_record_refuses (test_agentctl_w4.AgentctlW4Tests.test_row_projection_ambiguous_record_refuses) ... ok +test_row_projection_cas_and_insert (test_agentctl_w4.AgentctlW4Tests.test_row_projection_cas_and_insert) ... ok +test_cwd_visibility_structured_on_host (test_agentctl_w4.DarwinCwdVisibilityTests.test_cwd_visibility_structured_on_host) ... ok +test_cleanup_report_never_deletes (test_codegraph_governance.CodegraphGovernanceTest.test_cleanup_report_never_deletes) ... ok +test_codegraph_sync_fail_open_without_binary (test_codegraph_governance.CodegraphGovernanceTest.test_codegraph_sync_fail_open_without_binary) ... ok +test_create_skips_codegraph_when_missing (test_codegraph_governance.CodegraphGovernanceTest.test_create_skips_codegraph_when_missing) ... ok +test_docs_drift_detects_done_regression (test_codegraph_governance.CodegraphGovernanceTest.test_docs_drift_detects_done_regression) ... ok +test_hooks_install_includes_codegraph_sync_hooks (test_codegraph_governance.CodegraphGovernanceTest.test_hooks_install_includes_codegraph_sync_hooks) ... ok +test_emits_empty_object (test_cursor_hooks.AuditSessionTest.test_emits_empty_object) ... ok +test_probe_writes_when_enabled (test_cursor_hooks.AuditSessionTest.test_probe_writes_when_enabled) ... ok +test_hooks_json_parseable_and_scripts_executable (test_cursor_hooks.CursorHooksStructureTest.test_hooks_json_parseable_and_scripts_executable) ... ok +test_mcp_relative_paths_for_agentctl_jev (test_cursor_hooks.CursorHooksStructureTest.test_mcp_relative_paths_for_agentctl_jev) ... ok +test_rules_no_drift_markers (test_cursor_hooks.CursorHooksStructureTest.test_rules_no_drift_markers) ... ok +test_allow_status (test_cursor_hooks.GuardGitTest.test_allow_status) ... ok +test_ask_branch_D (test_cursor_hooks.GuardGitTest.test_ask_branch_D) ... ok +test_ask_clean_f (test_cursor_hooks.GuardGitTest.test_ask_clean_f) ... ok +test_ask_force (test_cursor_hooks.GuardGitTest.test_ask_force) ... ok +test_ask_push (test_cursor_hooks.GuardGitTest.test_ask_push) ... ok +test_ask_reset_hard (test_cursor_hooks.GuardGitTest.test_ask_reset_hard) ... ok +test_allow_result (test_cursor_hooks.GuardPiDispatchTest.test_allow_result) ... ok +test_allow_roles (test_cursor_hooks.GuardPiDispatchTest.test_allow_roles) ... ok +test_allow_test_with_commands (test_cursor_hooks.GuardPiDispatchTest.test_allow_test_with_commands) ... ok +test_allow_valid_run (test_cursor_hooks.GuardPiDispatchTest.test_allow_valid_run) ... ok +test_alt_field_names_arguments (test_cursor_hooks.GuardPiDispatchTest.test_alt_field_names_arguments) ... ok +test_ask_model_override (test_cursor_hooks.GuardPiDispatchTest.test_ask_model_override) ... ok +test_ask_source_sha_head (test_cursor_hooks.GuardPiDispatchTest.test_ask_source_sha_head) ... ok +test_ask_source_sha_short (test_cursor_hooks.GuardPiDispatchTest.test_ask_source_sha_short) ... ok +test_ask_test_bad_digest (test_cursor_hooks.GuardPiDispatchTest.test_ask_test_bad_digest) ... ok +test_ask_test_missing_commands (test_cursor_hooks.GuardPiDispatchTest.test_ask_test_missing_commands) ... ok +test_fail_open_empty_json (test_cursor_hooks.GuardPiDispatchTest.test_fail_open_empty_json) ... ok +test_allow_leased_run (test_cursor_hooks.GuardPiShellTest.test_allow_leased_run) ... ok +test_allow_roles (test_cursor_hooks.GuardPiShellTest.test_allow_roles) ... ok +test_ask_bare_run (test_cursor_hooks.GuardPiShellTest.test_ask_bare_run) ... ok +test_ask_pkill_f (test_cursor_hooks.GuardPiShellTest.test_ask_pkill_f) ... ok +test_injects_for_pi_tool (test_cursor_hooks.PiReceiptContextTest.test_injects_for_pi_tool) ... ok +test_noop_for_unrelated (test_cursor_hooks.PiReceiptContextTest.test_noop_for_unrelated) ... ok +test_skills_exist (test_cursor_hooks.SkillsPresentTest.test_skills_exist) ... ok +test_both_expected_tests_pass (test_go_test_evidence.TestEvidence.test_both_expected_tests_pass) ... ok +test_missing_partial_skip_failure_and_duplicate_are_not_pass (test_go_test_evidence.TestEvidence.test_missing_partial_skip_failure_and_duplicate_are_not_pass) ... ok +test_subtests_and_case_mismatches_do_not_prove_parent_execution (test_go_test_evidence.TestEvidence.test_subtests_and_case_mismatches_do_not_prove_parent_execution) ... ok +test_all_profiles_have_noninteractive_flags_and_explicit_pi_routes (test_herdr_agents.HerdrTests.test_all_profiles_have_noninteractive_flags_and_explicit_pi_routes) ... ok +test_explicit_prewrite_retry_reaches_flash (test_herdr_agents.HerdrTests.test_explicit_prewrite_retry_reaches_flash) ... ok +test_implementation_fallback_is_explicit (test_herdr_agents.HerdrTests.test_implementation_fallback_is_explicit) ... ok +test_kimi_atomic_limit_across_eight_processes (test_herdr_agents.HerdrTests.test_kimi_atomic_limit_across_eight_processes) ... ok +test_kimi_reason_disabled_marker_and_cross_lane_budget (test_herdr_agents.HerdrTests.test_kimi_reason_disabled_marker_and_cross_lane_budget) ... ok +test_kimi_uncertain_send_consumes_budget (test_herdr_agents.HerdrTests.test_kimi_uncertain_send_consumes_budget) ... ok +test_main_or_wrong_target_pane_cannot_start (test_herdr_agents.HerdrTests.test_main_or_wrong_target_pane_cannot_start) ... ok +test_outside_herdr_and_wrong_task_cwd_never_contact_transport (test_herdr_agents.HerdrTests.test_outside_herdr_and_wrong_task_cwd_never_contact_transport) ... ok +test_report_routes_to_max_not_kimi_or_minimax (test_herdr_agents.HerdrTests.test_report_routes_to_max_not_kimi_or_minimax) ... ok +test_same_role_ambiguity_and_foreign_cwd_do_not_dispatch (test_herdr_agents.HerdrTests.test_same_role_ambiguity_and_foreign_cwd_do_not_dispatch) ... ok +test_uncertain_transport_error_and_blocked_do_not_fallback (test_herdr_agents.HerdrTests.test_uncertain_transport_error_and_blocked_do_not_fallback) ... ok +test_authorized_pinned_rebuild_preserves_no_build (test_ops_guards.OpsGuardsTest.test_authorized_pinned_rebuild_preserves_no_build) ... ok +test_build_accepts_nightly_evidence_excluded_from_build_context (test_ops_guards.OpsGuardsTest.test_build_accepts_nightly_evidence_excluded_from_build_context) ... ok +test_build_context_is_frozen_when_managed_source_changes (test_ops_guards.OpsGuardsTest.test_build_context_is_frozen_when_managed_source_changes) ... ok +test_build_dirty_source_refuses_before_docker_or_receipts (test_ops_guards.OpsGuardsTest.test_build_dirty_source_refuses_before_docker_or_receipts) ... ok +test_build_mismatched_commit_refuses_before_docker_or_receipts (test_ops_guards.OpsGuardsTest.test_build_mismatched_commit_refuses_before_docker_or_receipts) ... ok +test_build_untracked_source_refuses_before_docker_or_receipts (test_ops_guards.OpsGuardsTest.test_build_untracked_source_refuses_before_docker_or_receipts) ... ok +test_clean_build_records_exact_source_commit (test_ops_guards.OpsGuardsTest.test_clean_build_records_exact_source_commit) ... ok +test_environment_lease_cannot_deploy_dirty_main_source (test_ops_guards.OpsGuardsTest.test_environment_lease_cannot_deploy_dirty_main_source) ... ok +test_invalid_action_does_not_initialize_environment (test_ops_guards.OpsGuardsTest.test_invalid_action_does_not_initialize_environment) ... ok +test_linked_worktree_writes_refuse_and_queries_remain_read_only (test_ops_guards.OpsGuardsTest.test_linked_worktree_writes_refuse_and_queries_remain_read_only) ... ok +test_one_environment_lease_does_not_authorize_all (test_ops_guards.OpsGuardsTest.test_one_environment_lease_does_not_authorize_all) ... ok +test_queries_never_initialize_environment (test_ops_guards.OpsGuardsTest.test_queries_never_initialize_environment) ... ok +test_service_writes_without_lease_refuse_before_any_side_effect (test_ops_guards.OpsGuardsTest.test_service_writes_without_lease_refuse_before_any_side_effect) ... ok +test_valid_lease_cannot_hide_linked_caller_or_linked_script (test_ops_guards.OpsGuardsTest.test_valid_lease_cannot_hide_linked_caller_or_linked_script) ... ok +test_00_executor_module_present (test_pi_subagents.PiSubagentsTests.test_00_executor_module_present) ... ok +test_approved_commands_do_not_mutate_model_snapshot (test_pi_subagents.PiSubagentsTests.test_approved_commands_do_not_mutate_model_snapshot) ... ok +test_config_catalog_parses_known_pool_and_roles (test_pi_subagents.PiSubagentsTests.test_config_catalog_parses_known_pool_and_roles) ... ok +test_config_rejects_unknown_alias_and_cycle (test_pi_subagents.PiSubagentsTests.test_config_rejects_unknown_alias_and_cycle) ... ok +test_config_reload_snapshot_on_dispatch (test_pi_subagents.PiSubagentsTests.test_config_reload_snapshot_on_dispatch) ... ok +test_corrupt_receipt_fail_closed (test_pi_subagents.PiSubagentsTests.test_corrupt_receipt_fail_closed) ... ok +test_crash_before_receipt_terminal_is_unknown_on_status (test_pi_subagents.PiSubagentsTests.test_crash_before_receipt_terminal_is_unknown_on_status) ... ok +test_disabled_auxiliary_and_m3_critical_remap_denied (test_pi_subagents.PiSubagentsTests.test_disabled_auxiliary_and_m3_critical_remap_denied) ... ok +test_extension_outside_model_snapshot_inventory (test_pi_subagents.PiSubagentsTests.test_extension_outside_model_snapshot_inventory) +Model snapshot must contain only declared inputs; -e guard lives outside it. ... ok +test_failed_replay_stays_failure (test_pi_subagents.PiSubagentsTests.test_failed_replay_stays_failure) ... ok +test_forged_lease_env_refused (test_pi_subagents.PiSubagentsTests.test_forged_lease_env_refused) ... ok +test_global_concurrency_limit (test_pi_subagents.PiSubagentsTests.test_global_concurrency_limit) ... ok +test_idempotent_replay_and_digest_mismatch (test_pi_subagents.PiSubagentsTests.test_idempotent_replay_and_digest_mismatch) ... ok +test_input_path_traversal_symlink_secret_refusal (test_pi_subagents.PiSubagentsTests.test_input_path_traversal_symlink_secret_refusal) ... ok +test_leader_exit_reaps_orphan_child (test_pi_subagents.PiSubagentsTests.test_leader_exit_reaps_orphan_child) ... ok +test_malformed_and_empty_outputs_fail (test_pi_subagents.PiSubagentsTests.test_malformed_and_empty_outputs_fail) ... ok +test_mcp_from_worktree_uses_main_agentctl (test_pi_subagents.PiSubagentsTests.test_mcp_from_worktree_uses_main_agentctl) +Server INSTALL_ROOT may be a linked worktree; agentctl must resolve from main. ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=7 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=4 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=5 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +ok +test_mcp_initialize_list_run_result (test_pi_subagents.PiSubagentsTests.test_mcp_initialize_list_run_result) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=7 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=4 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=5 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +ok +test_mcp_running_poll_is_not_error (test_pi_subagents.PiSubagentsTests.test_mcp_running_poll_is_not_error) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=7 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=4 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=5 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +ok +test_mcp_surfaces_role_disabled_and_scope_refusal (test_pi_subagents.PiSubagentsTests.test_mcp_surfaces_role_disabled_and_scope_refusal) ... /home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=10 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=6 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=8 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=7 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=4 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +/home/linuxbrew/.linuxbrew/Cellar/python@3.14/3.14.7/lib/python3.14/unittest/case.py:615: ResourceWarning: unclosed file <_io.TextIOWrapper name=5 encoding='UTF-8'> + result = method() +ResourceWarning: Enable tracemalloc to get the object allocation traceback +ok +test_model_mismatch_fails (test_pi_subagents.PiSubagentsTests.test_model_mismatch_fails) ... ok +test_naked_run_without_lease_refused (test_pi_subagents.PiSubagentsTests.test_naked_run_without_lease_refused) ... ok +test_orphan_result_is_readonly (test_pi_subagents.PiSubagentsTests.test_orphan_result_is_readonly) ... ok +test_output_cap_on_stdout_flood (test_pi_subagents.PiSubagentsTests.test_output_cap_on_stdout_flood) ... ok +test_readonly_guard_allows_exact_root_listing (test_pi_subagents.PiSubagentsTests.test_readonly_guard_allows_exact_root_listing) +Exact-root ls/grep must pass when snapshot inventory is allowlisted-only. ... ok +test_readonly_guard_rejects_at_prefix (test_pi_subagents.PiSubagentsTests.test_readonly_guard_rejects_at_prefix) +Exercise extension path policy via Node strip-types if available. ... ok +test_replay_keeps_old_config_after_disable (test_pi_subagents.PiSubagentsTests.test_replay_keeps_old_config_after_disable) ... ok +test_roles_does_not_invoke_pi (test_pi_subagents.PiSubagentsTests.test_roles_does_not_invoke_pi) ... ok +test_run_emits_exact_pi_flags_and_receipt (test_pi_subagents.PiSubagentsTests.test_run_emits_exact_pi_flags_and_receipt) ... ok +test_scope_refusal_outside_report_path (test_pi_subagents.PiSubagentsTests.test_scope_refusal_outside_report_path) ... ok +test_scrub_env_drops_pi_dir_for_tests (test_pi_subagents.PiSubagentsTests.test_scrub_env_drops_pi_dir_for_tests) ... ok +test_source_sha_rejects_symbolic_refs (test_pi_subagents.PiSubagentsTests.test_source_sha_rejects_symbolic_refs) ... ok +test_test_run_requires_approved_commands_and_records_failure (test_pi_subagents.PiSubagentsTests.test_test_run_requires_approved_commands_and_records_failure) ... ok +test_timeout_kills_process_group (test_pi_subagents.PiSubagentsTests.test_timeout_kills_process_group) ... ok + +---------------------------------------------------------------------- +Ran 202 tests in 111.351s + +OK diff --git a/scripts/tests/test_cursor_hooks.py b/scripts/tests/test_cursor_hooks.py new file mode 100644 index 00000000..6e01de5b --- /dev/null +++ b/scripts/tests/test_cursor_hooks.py @@ -0,0 +1,352 @@ +"""Unit tests for project Cursor hooks (advisory gates) and hooks.json structure.""" +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] +REPO = SOURCE.parent +HOOKS = REPO / ".cursor" / "hooks" +HOOKS_JSON = REPO / ".cursor" / "hooks.json" +RULES = REPO / ".cursor" / "rules" + + +def _run_hook(script: Path, payload: dict | str, *, env: dict | None = None, cwd: Path | None = None) -> tuple[int, dict | None, str]: + data = payload if isinstance(payload, str) else json.dumps(payload) + e = os.environ.copy() + e["CURSOR_HOOKS_NO_INVENTORY"] = "1" + if env: + e.update(env) + # Isolate audit/probe under temp repo root via CURSOR_PROJECT_DIR + r = subprocess.run( + [str(script)], + input=data, + text=True, + capture_output=True, + cwd=str(cwd or REPO), + env=e, + check=False, + ) + out = (r.stdout or "").strip() + parsed = None + if out: + try: + parsed = json.loads(out) + except json.JSONDecodeError: + parsed = None + return r.returncode, parsed, r.stderr or "" + + +class CursorHooksStructureTest(unittest.TestCase): + def test_hooks_json_parseable_and_scripts_executable(self): + cfg = json.loads(HOOKS_JSON.read_text(encoding="utf-8")) + self.assertEqual(cfg.get("version"), 1) + hooks = cfg.get("hooks") or {} + self.assertTrue(hooks) + for event, entries in hooks.items(): + self.assertIsInstance(entries, list, event) + for entry in entries: + cmd = entry.get("command") + self.assertTrue(cmd, f"{event} missing command") + path = REPO / cmd + self.assertTrue(path.is_file(), f"missing {path}") + mode = path.stat().st_mode + self.assertTrue(mode & stat.S_IXUSR, f"not executable: {path}") + matcher = entry.get("matcher") + if matcher: + # JavaScript RegExp must compile in Node + r = subprocess.run( + ["node", "-e", f"new RegExp({json.dumps(matcher)})"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(r.returncode, 0, f"bad matcher {matcher!r}: {r.stderr}") + + def test_rules_no_drift_markers(self): + text = (RULES / "test-reports-standard.mdc").read_text(encoding="utf-8") + self.assertNotIn("MiniMax-M2.7-highspeed", text) + self.assertNotIn("PROJECT.md) §8", text) + self.assertNotIn("PROJECT.md](../../PROJECT.md) §8", text) + # No unconditional full go test requirement as acceptance + self.assertNotIn("go test -v ./...", text) + self.assertIn("验证契约", text) + + def test_mcp_relative_paths_for_agentctl_jev(self): + mcp = json.loads((REPO / ".cursor" / "mcp.json").read_text(encoding="utf-8")) + for name in ("agentctl", "jev", "pi-subagents"): + args = mcp["mcpServers"][name]["args"] + self.assertTrue(any(a.startswith("scripts/") for a in args), name) + self.assertFalse(any("${workspaceFolder}" in a for a in args), name) + + +class GuardGitTest(unittest.TestCase): + SCRIPT = HOOKS / "guard-git.sh" + + def _perm(self, command: str) -> str: + code, parsed, err = _run_hook(self.SCRIPT, {"command": command}) + self.assertEqual(code, 0, err) + self.assertIsInstance(parsed, dict) + return parsed.get("permission") + + def test_allow_status(self): + self.assertEqual(self._perm("git status"), "allow") + + def test_ask_push(self): + self.assertEqual(self._perm("git push origin dev"), "ask") + + def test_ask_force(self): + self.assertEqual(self._perm("git push --force origin main"), "ask") + + def test_ask_reset_hard(self): + self.assertEqual(self._perm("git reset --hard HEAD~1"), "ask") + + def test_ask_clean_f(self): + self.assertEqual(self._perm("git clean -fd"), "ask") + + def test_ask_branch_D(self): + self.assertEqual(self._perm("git branch -D feature/x"), "ask") + + +class GuardPiDispatchTest(unittest.TestCase): + SCRIPT = HOOKS / "guard-pi-dispatch.sh" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / ".cursor").mkdir() + self.env = {"CURSOR_PROJECT_DIR": str(self.root), "CURSOR_HOOKS_NO_INVENTORY": "1"} + + def tearDown(self): + self.tmp.cleanup() + + def _run(self, payload: dict) -> dict: + code, parsed, err = _run_hook(self.SCRIPT, payload, env=self.env, cwd=self.root) + self.assertEqual(code, 0, err) + self.assertIsInstance(parsed, dict) + return parsed + + def _valid_run(self, **overrides) -> dict: + req = { + "role_id": "research", + "request_id": "research-001", + "goal": "read PROJECT.md", + "authorization_ref": "task auth", + "source_sha": "cfc80b34a40e8de78e77a44839571bb684d405d5", + "input_files": ["PROJECT.md"], + "timeout_seconds": 120, + } + req.update(overrides) + return { + "mcp_server_name": "pi-subagents", + "tool_name": "pi_subagent_run", + "tool_input": {"worktree": "/tmp/wt", "task_id": "t1", "request": req}, + } + + def test_allow_valid_run(self): + self.assertEqual(self._run(self._valid_run()).get("permission"), "allow") + + def test_allow_roles(self): + p = { + "mcp_server_name": "pi-subagents", + "tool_name": "pi_subagent_roles", + "tool_input": {"worktree": "/tmp/wt", "task_id": "t1"}, + } + self.assertEqual(self._run(p).get("permission"), "allow") + + def test_allow_result(self): + p = { + "tool_name": "pi_subagent_result", + "arguments": {"worktree": "/tmp/wt", "task_id": "t1", "request_id": "r1"}, + } + self.assertEqual(self._run(p).get("permission"), "allow") + + def test_ask_source_sha_head(self): + self.assertEqual(self._run(self._valid_run(source_sha="HEAD")).get("permission"), "ask") + + def test_ask_source_sha_short(self): + self.assertEqual(self._run(self._valid_run(source_sha="cfc80b34")).get("permission"), "ask") + + def test_ask_test_missing_commands(self): + p = self._valid_run(role_id="test_run") + self.assertEqual(self._run(p).get("permission"), "ask") + + def test_ask_test_bad_digest(self): + p = self._valid_run( + role_id="acceptance_check", + approved_commands=[["true"]], + plan_digest="not-a-digest", + ) + self.assertEqual(self._run(p).get("permission"), "ask") + + def test_allow_test_with_commands(self): + digest = "a" * 64 + p = self._valid_run( + role_id="test_run", + approved_commands=[["python3", "-c", "print(1)"]], + plan_digest=digest, + ) + self.assertEqual(self._run(p).get("permission"), "allow") + + def test_ask_model_override(self): + p = self._valid_run(model_override={"provider": "x", "model": "y"}) + self.assertEqual(self._run(p).get("permission"), "ask") + + def test_fail_open_empty_json(self): + code, parsed, err = _run_hook(self.SCRIPT, "", env=self.env, cwd=self.root) + self.assertEqual(code, 0, err) + self.assertEqual(parsed.get("permission"), "allow") + audit = (self.root / ".cursor" / "hook-audit.jsonl").read_text(encoding="utf-8") + self.assertIn("UNRECOGNIZED_INPUT", audit) + + def test_alt_field_names_arguments(self): + # tool_input via "arguments" + payload = { + "name": "pi_subagent_run", + "arguments": { + "request": { + "role_id": "planning", + "request_id": "p-1", + "goal": "g", + "authorization_ref": "a", + "source_sha": "cfc80b34a40e8de78e77a44839571bb684d405d5", + "input_files": [], + "timeout_seconds": 30, + } + }, + } + self.assertEqual(self._run(payload).get("permission"), "allow") + + +class GuardPiShellTest(unittest.TestCase): + SCRIPT = HOOKS / "guard-pi-shell.sh" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / ".cursor").mkdir() + self.env = {"CURSOR_PROJECT_DIR": str(self.root)} + + def tearDown(self): + self.tmp.cleanup() + + def _perm(self, command: str) -> str: + code, parsed, err = _run_hook( + self.SCRIPT, {"command": command}, env=self.env, cwd=self.root + ) + self.assertEqual(code, 0, err) + return parsed.get("permission") + + def test_allow_roles(self): + self.assertEqual(self._perm("python3 -B scripts/pi_subagents.py roles"), "allow") + + def test_ask_bare_run(self): + self.assertEqual( + self._perm("python3 -B scripts/pi_subagents.py run --request /tmp/r.json"), + "ask", + ) + + def test_allow_leased_run(self): + cmd = ( + "python3 scripts/agentctl.py with --task t1 " + "--resource path:.doc/test-reports/assets/pi-subagents/t1/r1 -- " + "python3 -B scripts/pi_subagents.py run --request /tmp/r.json" + ) + self.assertEqual(self._perm(cmd), "allow") + + def test_ask_pkill_f(self): + self.assertEqual(self._perm("pkill -f pi_subagents"), "ask") + + +class PiReceiptContextTest(unittest.TestCase): + SCRIPT = HOOKS / "pi-receipt-context.sh" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / ".cursor").mkdir() + self.env = {"CURSOR_PROJECT_DIR": str(self.root)} + + def tearDown(self): + self.tmp.cleanup() + + def test_injects_for_pi_tool(self): + code, parsed, err = _run_hook( + self.SCRIPT, + {"tool_name": "pi_subagent_result", "mcp_server_name": "pi-subagents"}, + env=self.env, + cwd=self.root, + ) + self.assertEqual(code, 0, err) + self.assertIn("additional_context", parsed) + self.assertIn("run_status", parsed["additional_context"]) + + def test_noop_for_unrelated(self): + code, parsed, err = _run_hook( + self.SCRIPT, + {"tool_name": "Read", "tool_input": {"path": "x"}}, + env=self.env, + cwd=self.root, + ) + self.assertEqual(code, 0, err) + self.assertEqual(parsed, {}) + + +class AuditSessionTest(unittest.TestCase): + SCRIPT = HOOKS / "audit-session.sh" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + (self.root / ".cursor").mkdir() + # Minimal fake repo so inventory finds no agentctl and skips cleanly + self.env = { + "CURSOR_PROJECT_DIR": str(self.root), + "CURSOR_HOOKS_NO_INVENTORY": "1", + } + + def tearDown(self): + self.tmp.cleanup() + + def test_emits_empty_object(self): + code, parsed, err = _run_hook( + self.SCRIPT, + {"hook_event_name": "sessionStart"}, + env=self.env, + cwd=self.root, + ) + self.assertEqual(code, 0, err) + self.assertEqual(parsed, {}) + audit = (self.root / ".cursor" / "hook-audit.jsonl").read_text(encoding="utf-8") + self.assertIn("SESSION_AUDIT", audit) + + def test_probe_writes_when_enabled(self): + env = dict(self.env) + env["CURSOR_HOOKS_PROBE"] = "1" + code, _, err = _run_hook( + self.SCRIPT, {"event": "stop"}, env=env, cwd=self.root + ) + self.assertEqual(code, 0, err) + probe = self.root / ".cursor" / "hook-probe.jsonl" + self.assertTrue(probe.is_file()) + self.assertIn("audit-session", probe.read_text(encoding="utf-8")) + + +class SkillsPresentTest(unittest.TestCase): + def test_skills_exist(self): + for name in ("pi-subagent-dispatch", "subagent-stage-record"): + p = REPO / ".cursor" / "skills" / name / "SKILL.md" + self.assertTrue(p.is_file(), p) + text = p.read_text(encoding="utf-8") + self.assertIn("name:", text) + + +if __name__ == "__main__": + unittest.main()