✨ feat(workflow): enforce auditable agent rules
This commit is contained in:
+226
-44
@@ -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
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user