feat(workflow): enforce auditable agent rules

This commit is contained in:
csh
2026-08-12 12:40:31 +08:00
parent 1d9590f6a1
commit 91b6f8453e
7 changed files with 1273 additions and 318 deletions
+226 -44
View File
@@ -3,6 +3,7 @@ import argparse
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
import os
import re
@@ -60,6 +61,7 @@ ALLOWED_STATUSES = {
SATISFIED_STATUSES = {"resolved", "skipped"}
THREAD_LOCKS: dict[str, threading.Lock] = {}
THREAD_LOCKS_GUARD = threading.Lock()
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
class StateError(ValueError):
@@ -111,6 +113,13 @@ class Feature:
return "queued"
@dataclass(frozen=True)
class EvidenceArtifact:
data: dict[str, Any]
digest: str
source: Path
def _thread_lock(lock_path: Path) -> threading.Lock:
key = str(lock_path.resolve())
with THREAD_LOCKS_GUARD:
@@ -889,42 +898,121 @@ def claim_ticket(
return render_claim_context(feature, ticket, metadata)
def evidence_field(
evidence: str,
key: str,
label: str,
expected: Optional[str] = None,
) -> str:
matches = re.findall(
rf"(?:^|[;,])\s*{re.escape(key)}\s*=\s*([^;,]+)",
evidence,
re.IGNORECASE,
)
if not matches or not matches[0].strip():
required = f"{key}={expected}" if expected is not None else key
raise StateError(f"{label} evidence must include {required}")
if len(matches) != 1:
raise StateError(f"{label} evidence must include exactly one {key}")
value = matches[0].strip()
if expected is not None and value.lower() != expected.lower():
raise StateError(f"{label} evidence must include {key}={expected}")
return value
def require_review_passed(reviewed: str) -> tuple[str, str]:
for axis in ("standards", "spec"):
evidence_field(reviewed, axis, "review", "pass")
def canonical_json_bytes(value: dict[str, Any]) -> bytes:
return (
evidence_field(reviewed, "commit", "review"),
evidence_field(reviewed, "base", "review"),
json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
+ "\n"
).encode("utf-8")
def artifact_string(data: dict[str, Any], key: str, label: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise StateError(f"{label} evidence artifact field {key} must be a non-empty string")
return value.strip()
def artifact_pass(data: dict[str, Any], key: str, label: str) -> None:
value = artifact_string(data, key, label)
if value != "pass":
raise StateError(f"{label} evidence artifact field {key} must equal pass")
def load_evidence_artifact(
raw_path: str,
label: str,
expected_kind: str,
) -> EvidenceArtifact:
if not raw_path.strip():
raise StateError(f"{label} evidence artifact is required")
source = Path(raw_path).expanduser().resolve()
if not source.is_file():
raise StateError(f"{label} evidence artifact not found: {source}")
try:
data = json.loads(source.read_text(encoding="utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise StateError(f"{label} evidence artifact must be valid UTF-8 JSON") from exc
if not isinstance(data, dict):
raise StateError(f"{label} evidence artifact must be a JSON object")
if data.get("version") != 1:
raise StateError(f"{label} evidence artifact version must equal 1")
if data.get("kind") != expected_kind:
raise StateError(
f"{label} evidence artifact kind must equal {expected_kind}"
)
artifact_string(data, "commit", label)
if expected_kind == "verification":
artifact_pass(data, "result", label)
artifact_string(data, "command", label)
if data.get("exit_code") != 0 or isinstance(data.get("exit_code"), bool):
raise StateError(f"{label} evidence artifact exit_code must equal 0")
output = artifact_string(data, "output", label)
output_sha256 = artifact_string(data, "output_sha256", label)
if not SHA256_RE.fullmatch(output_sha256):
raise StateError(
f"{label} evidence artifact output_sha256 must be a SHA-256 digest"
)
if hashlib.sha256(output.encode("utf-8")).hexdigest() != output_sha256:
raise StateError(f"{label} evidence artifact output_sha256 mismatch")
else:
artifact_string(data, "base", label)
artifact_pass(data, "standards", label)
artifact_pass(data, "spec", label)
report = artifact_string(data, "report", label)
report_sha256 = artifact_string(data, "report_sha256", label)
if not SHA256_RE.fullmatch(report_sha256):
raise StateError(
f"{label} evidence artifact report_sha256 must be a SHA-256 digest"
)
if hashlib.sha256(report.encode("utf-8")).hexdigest() != report_sha256:
raise StateError(f"{label} evidence artifact report_sha256 mismatch")
canonical = canonical_json_bytes(data)
return EvidenceArtifact(
data=data,
digest=hashlib.sha256(canonical).hexdigest(),
source=source,
)
def require_verification_passed(evidence: str, label: str) -> str:
if not evidence.strip():
raise StateError(f"{label} evidence is required")
evidence_field(evidence, "result", label, "pass")
return evidence_field(evidence, "commit", label)
def snapshot_evidence(
feature: Feature,
purpose: str,
artifact: EvidenceArtifact,
) -> dict[str, str]:
filename = f"{purpose}-{artifact.digest[:16]}.json"
relative_path = Path("evidence") / filename
atomic_write_text(
feature.path / relative_path,
json.dumps(
artifact.data,
ensure_ascii=True,
indent=2,
sort_keys=True,
)
+ "\n",
)
return {
"path": relative_path.as_posix(),
"sha256": artifact.digest,
}
def require_review_passed(reviewed: str) -> tuple[str, str, EvidenceArtifact]:
artifact = load_evidence_artifact(reviewed, "review", "review")
return (
artifact_string(artifact.data, "commit", "review"),
artifact_string(artifact.data, "base", "review"),
artifact,
)
def require_verification_passed(
evidence: str, label: str
) -> tuple[str, EvidenceArtifact]:
artifact = load_evidence_artifact(evidence, label, "verification")
return artifact_string(artifact.data, "commit", label), artifact
def normalize_commit(repo_root: Path, commit: str, label: str) -> str:
@@ -1110,6 +1198,57 @@ def finish_nonresolved_ticket(
return f"{result.upper()}={feature_slug}/{ticket_number}"
def release_blocked_ticket(
state_root: Path,
feature_slug: str,
ticket_number: str,
reason: str,
) -> str:
"""Return a blocked ticket to the queue without its original owner.
``finish --result released`` is owner-scoped and ``reclaim`` only accepts
claimed tickets, so a blocked ticket whose session is gone would otherwise
have no owner left that could unblock it. Branch, worktree and uncommitted
changes are preserved exactly as ``finish --result released`` leaves them.
"""
if not reason.strip():
raise StateError("ticket release requires a reason")
state_root = state_root.resolve()
with locked_state(state_root):
feature = load_feature(state_root, feature_slug)
ticket = feature.tickets.get(ticket_number)
if ticket is None:
raise StateError(f"{feature_slug}: ticket {ticket_number} not found")
if ticket.status != "blocked":
raise StateError(
f"ticket {feature_slug}/{ticket_number} is not blocked: {ticket.status}"
)
metadata = dict(ticket.metadata)
previous_owner = str(metadata.get("claimed_by", ""))
timestamp = utc_now()
append_history(
metadata,
"ticket-released",
timestamp,
reason=reason.strip(),
previous_owner=previous_owner,
)
if previous_owner:
metadata["last_owner"] = previous_owner
metadata["released_at"] = timestamp
metadata["released_reason"] = reason.strip()
for key in (
"blocked_reason",
"blocked_at",
"claimed_by",
"claimed_at",
"heartbeat_at",
):
metadata.pop(key, None)
update_ticket_state(ticket, status="ready-for-agent", metadata=metadata)
return f"TICKET_RELEASED={feature_slug}/{ticket_number}"
def finish_resolved_ticket(
state_root: Path,
repo_root: Path,
@@ -1128,8 +1267,10 @@ def finish_resolved_ticket(
raise StateError("feature head is required")
if not review_base:
raise StateError("review base is required")
verification_commit = require_verification_passed(verified, "verification")
review_commit, reviewed_base = require_review_passed(reviewed)
verification_commit, verification_artifact = require_verification_passed(
verified, "verification"
)
review_commit, reviewed_base, review_artifact = require_review_passed(reviewed)
state_root = state_root.resolve()
repo_root = resolve_repo_root(repo_root)
@@ -1271,13 +1412,24 @@ def finish_resolved_ticket(
if recovered_integration_commit is not None
else git_output(repo_root, "rev-parse", feature_branch)
)
evidence = {
"verification": snapshot_evidence(
feature,
f"ticket-{ticket_number}-verification",
verification_artifact,
),
"review": snapshot_evidence(
feature,
f"ticket-{ticket_number}-review",
review_artifact,
),
}
metadata.update(
{
"implementation_commit": implementation_commit,
"ticket_head": ticket_tip,
"integration_commit": integration_commit,
"verified": verified,
"reviewed": reviewed,
"evidence": evidence,
"resolved_at": utc_now(),
}
)
@@ -1426,13 +1578,13 @@ def integrate_feature(
) -> str:
if not feature_head:
raise StateError("feature head is required")
feature_verification_commit = require_verification_passed(
feature_verification_commit, feature_verification_artifact = require_verification_passed(
verified, "feature verification"
)
main_verification_commit = require_verification_passed(
main_verification_commit, main_verification_artifact = require_verification_passed(
main_verified, "main candidate verification"
)
review_commit, reviewed_base = require_review_passed(reviewed)
review_commit, reviewed_base, review_artifact = require_review_passed(reviewed)
state_root = state_root.resolve()
repo_root = resolve_repo_root(repo_root)
@@ -1576,14 +1728,29 @@ def integrate_feature(
raise StateError(f"feature merge failed: {detail}")
integration_commit = git_output(repo_root, "rev-parse", main_branch)
evidence = {
"feature_verification": snapshot_evidence(
requested,
"feature-verification",
feature_verification_artifact,
),
"main_candidate_verification": snapshot_evidence(
requested,
"main-candidate-verification",
main_verification_artifact,
),
"review": snapshot_evidence(
requested,
"feature-review",
review_artifact,
),
}
requested_state.update(
{
"feature_branch": feature_branch,
"feature_head": current_feature_head,
"integration_commit": integration_commit,
"verified": verified,
"main_verified": main_verified,
"reviewed": reviewed,
"evidence": evidence,
"partial_authorized": bool(allow_partial),
"integrated_at": utc_now(),
}
@@ -1659,6 +1826,12 @@ def build_parser() -> argparse.ArgumentParser:
release_feature.add_argument("--state-root", default=".scratch")
release_feature.add_argument("--feature", required=True)
release_ticket = subparsers.add_parser("release-ticket")
release_ticket.add_argument("--state-root", default=".scratch")
release_ticket.add_argument("--feature", required=True)
release_ticket.add_argument("--ticket", required=True)
release_ticket.add_argument("--reason", required=True)
integrate = subparsers.add_parser("integrate")
integrate.add_argument("--state-root", default=".scratch")
integrate.add_argument("--repo-root", default=".")
@@ -1716,6 +1889,13 @@ def main(argv: list[str]) -> int:
state_root,
args.feature,
)
elif args.command == "release-ticket":
message = release_blocked_ticket(
state_root,
args.feature,
args.ticket,
args.reason,
)
elif args.command == "integrate":
message = integrate_feature(
state_root,
@@ -1728,7 +1908,7 @@ def main(argv: list[str]) -> int:
args.main_branch,
args.allow_partial,
)
elif args.result == "resolved":
elif args.command == "finish" and args.result == "resolved":
message = finish_resolved_ticket(
state_root,
Path(args.repo_root),
@@ -1741,7 +1921,7 @@ def main(argv: list[str]) -> int:
args.verified,
args.reviewed,
)
else:
elif args.command == "finish":
message = finish_nonresolved_ticket(
state_root,
args.feature,
@@ -1750,6 +1930,8 @@ def main(argv: list[str]) -> int:
args.result,
args.reason,
)
else:
raise StateError(f"unhandled command: {args.command}")
except (OSError, StateError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
+55 -22
View File
@@ -508,6 +508,32 @@ def extract_block_lines(text: str, start: str, end: str) -> list[str]:
_AGENTS_BLOCK_START = "<!-- playbook:agents:start -->"
_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->"
_RULES_BLOCK_START = "<!-- playbook:rules:start -->"
_RULES_BLOCK_END = "<!-- playbook:rules:end -->"
def replace_marked_block(
text: str,
block: list[str],
start_marker: str,
end_marker: str,
) -> str:
"""Swap the first start..end marked region for ``block``, keeping the rest."""
updated: list[str] = []
in_block = False
replaced = False
for line in text.splitlines():
if not replaced and line.strip() == start_marker:
updated.extend(block)
in_block = True
replaced = True
continue
if in_block:
if line.strip() == end_marker:
in_block = False
continue
updated.append(line)
return "\n".join(updated) + "\n"
def preserve_agents_subblock(block: list[str], agents_text: str) -> list[str]:
@@ -559,23 +585,10 @@ def update_agents_section(
agents_text = agents_path.read_text(encoding="utf-8")
if start_marker in agents_text:
block = preserve_agents_subblock(block, agents_text)
lines = agents_text.splitlines()
updated: list[str] = []
in_block = False
replaced = False
for line in lines:
if not replaced and line.strip() == start_marker:
updated.extend(block)
in_block = True
replaced = True
continue
if in_block:
if line.strip() == end_marker:
in_block = False
continue
updated.append(line)
agents_path.write_text(
"\n".join(updated) + "\n", encoding="utf-8", newline="\n"
replace_marked_block(agents_text, block, start_marker, end_marker),
encoding="utf-8",
newline="\n",
)
log("Updated: AGENTS.md (section)")
else:
@@ -741,9 +754,6 @@ def sync_rules_action(config: dict, context: dict) -> int:
rules_dst = project_root / "AGENT_RULES.md"
force = bool(config.get("force", False))
if rules_dst.exists() and not force:
log("AGENT_RULES.md already exists. Use force to overwrite.")
return 0
project_name = resolve_project_name(context)
playbook_scripts = resolve_playbook_scripts(context)
@@ -751,13 +761,36 @@ def sync_rules_action(config: dict, context: dict) -> int:
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
no_backup = bool(config.get("no_backup", False))
backup_path(rules_dst, no_backup)
text = rules_src.read_text(encoding="utf-8")
text = replace_placeholders(
text, project_name, date_value, playbook_scripts, playbook_root
)
rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8", newline="\n")
log("Synced: AGENT_RULES.md")
if rules_dst.exists() and not force:
# The process itself is Playbook-owned, so keep it upgradable: refresh the
# marked block in place and leave anything the project added outside it.
# Files predating the markers still need force, as before.
block = extract_block_lines(text, _RULES_BLOCK_START, _RULES_BLOCK_END)
existing = rules_dst.read_text(encoding="utf-8")
if not block:
log("Skip: rules markers not found in template")
return 0
if _RULES_BLOCK_START not in existing:
log("AGENT_RULES.md has no playbook:rules block. Use force to overwrite.")
return 0
updated = replace_marked_block(
existing, block, _RULES_BLOCK_START, _RULES_BLOCK_END
)
if updated == existing:
log("Unchanged: AGENT_RULES.md (section)")
else:
backup_path(rules_dst, no_backup)
rules_dst.write_text(updated, encoding="utf-8", newline="\n")
log("Updated: AGENT_RULES.md (section)")
else:
backup_path(rules_dst, no_backup)
rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8", newline="\n")
log("Synced: AGENT_RULES.md")
local_rules = project_root / "AGENT_RULES.local.md"
if not local_rules.exists():