✨ feat(workflow): enforce auditable agent rules
This commit is contained in:
+226
-44
@@ -3,6 +3,7 @@ import argparse
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -60,6 +61,7 @@ ALLOWED_STATUSES = {
|
|||||||
SATISFIED_STATUSES = {"resolved", "skipped"}
|
SATISFIED_STATUSES = {"resolved", "skipped"}
|
||||||
THREAD_LOCKS: dict[str, threading.Lock] = {}
|
THREAD_LOCKS: dict[str, threading.Lock] = {}
|
||||||
THREAD_LOCKS_GUARD = threading.Lock()
|
THREAD_LOCKS_GUARD = threading.Lock()
|
||||||
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
class StateError(ValueError):
|
class StateError(ValueError):
|
||||||
@@ -111,6 +113,13 @@ class Feature:
|
|||||||
return "queued"
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvidenceArtifact:
|
||||||
|
data: dict[str, Any]
|
||||||
|
digest: str
|
||||||
|
source: Path
|
||||||
|
|
||||||
|
|
||||||
def _thread_lock(lock_path: Path) -> threading.Lock:
|
def _thread_lock(lock_path: Path) -> threading.Lock:
|
||||||
key = str(lock_path.resolve())
|
key = str(lock_path.resolve())
|
||||||
with THREAD_LOCKS_GUARD:
|
with THREAD_LOCKS_GUARD:
|
||||||
@@ -889,42 +898,121 @@ def claim_ticket(
|
|||||||
return render_claim_context(feature, ticket, metadata)
|
return render_claim_context(feature, ticket, metadata)
|
||||||
|
|
||||||
|
|
||||||
def evidence_field(
|
def canonical_json_bytes(value: dict[str, Any]) -> bytes:
|
||||||
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")
|
|
||||||
return (
|
return (
|
||||||
evidence_field(reviewed, "commit", "review"),
|
json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||||
evidence_field(reviewed, "base", "review"),
|
+ "\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:
|
def snapshot_evidence(
|
||||||
if not evidence.strip():
|
feature: Feature,
|
||||||
raise StateError(f"{label} evidence is required")
|
purpose: str,
|
||||||
evidence_field(evidence, "result", label, "pass")
|
artifact: EvidenceArtifact,
|
||||||
return evidence_field(evidence, "commit", label)
|
) -> 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:
|
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}"
|
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(
|
def finish_resolved_ticket(
|
||||||
state_root: Path,
|
state_root: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
@@ -1128,8 +1267,10 @@ def finish_resolved_ticket(
|
|||||||
raise StateError("feature head is required")
|
raise StateError("feature head is required")
|
||||||
if not review_base:
|
if not review_base:
|
||||||
raise StateError("review base is required")
|
raise StateError("review base is required")
|
||||||
verification_commit = require_verification_passed(verified, "verification")
|
verification_commit, verification_artifact = require_verification_passed(
|
||||||
review_commit, reviewed_base = require_review_passed(reviewed)
|
verified, "verification"
|
||||||
|
)
|
||||||
|
review_commit, reviewed_base, review_artifact = require_review_passed(reviewed)
|
||||||
state_root = state_root.resolve()
|
state_root = state_root.resolve()
|
||||||
repo_root = resolve_repo_root(repo_root)
|
repo_root = resolve_repo_root(repo_root)
|
||||||
|
|
||||||
@@ -1271,13 +1412,24 @@ def finish_resolved_ticket(
|
|||||||
if recovered_integration_commit is not None
|
if recovered_integration_commit is not None
|
||||||
else git_output(repo_root, "rev-parse", feature_branch)
|
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(
|
metadata.update(
|
||||||
{
|
{
|
||||||
"implementation_commit": implementation_commit,
|
"implementation_commit": implementation_commit,
|
||||||
"ticket_head": ticket_tip,
|
"ticket_head": ticket_tip,
|
||||||
"integration_commit": integration_commit,
|
"integration_commit": integration_commit,
|
||||||
"verified": verified,
|
"evidence": evidence,
|
||||||
"reviewed": reviewed,
|
|
||||||
"resolved_at": utc_now(),
|
"resolved_at": utc_now(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1426,13 +1578,13 @@ def integrate_feature(
|
|||||||
) -> str:
|
) -> str:
|
||||||
if not feature_head:
|
if not feature_head:
|
||||||
raise StateError("feature head is required")
|
raise StateError("feature head is required")
|
||||||
feature_verification_commit = require_verification_passed(
|
feature_verification_commit, feature_verification_artifact = require_verification_passed(
|
||||||
verified, "feature verification"
|
verified, "feature verification"
|
||||||
)
|
)
|
||||||
main_verification_commit = require_verification_passed(
|
main_verification_commit, main_verification_artifact = require_verification_passed(
|
||||||
main_verified, "main candidate verification"
|
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()
|
state_root = state_root.resolve()
|
||||||
repo_root = resolve_repo_root(repo_root)
|
repo_root = resolve_repo_root(repo_root)
|
||||||
|
|
||||||
@@ -1576,14 +1728,29 @@ def integrate_feature(
|
|||||||
raise StateError(f"feature merge failed: {detail}")
|
raise StateError(f"feature merge failed: {detail}")
|
||||||
|
|
||||||
integration_commit = git_output(repo_root, "rev-parse", main_branch)
|
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(
|
requested_state.update(
|
||||||
{
|
{
|
||||||
"feature_branch": feature_branch,
|
"feature_branch": feature_branch,
|
||||||
"feature_head": current_feature_head,
|
"feature_head": current_feature_head,
|
||||||
"integration_commit": integration_commit,
|
"integration_commit": integration_commit,
|
||||||
"verified": verified,
|
"evidence": evidence,
|
||||||
"main_verified": main_verified,
|
|
||||||
"reviewed": reviewed,
|
|
||||||
"partial_authorized": bool(allow_partial),
|
"partial_authorized": bool(allow_partial),
|
||||||
"integrated_at": utc_now(),
|
"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("--state-root", default=".scratch")
|
||||||
release_feature.add_argument("--feature", required=True)
|
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 = subparsers.add_parser("integrate")
|
||||||
integrate.add_argument("--state-root", default=".scratch")
|
integrate.add_argument("--state-root", default=".scratch")
|
||||||
integrate.add_argument("--repo-root", default=".")
|
integrate.add_argument("--repo-root", default=".")
|
||||||
@@ -1716,6 +1889,13 @@ def main(argv: list[str]) -> int:
|
|||||||
state_root,
|
state_root,
|
||||||
args.feature,
|
args.feature,
|
||||||
)
|
)
|
||||||
|
elif args.command == "release-ticket":
|
||||||
|
message = release_blocked_ticket(
|
||||||
|
state_root,
|
||||||
|
args.feature,
|
||||||
|
args.ticket,
|
||||||
|
args.reason,
|
||||||
|
)
|
||||||
elif args.command == "integrate":
|
elif args.command == "integrate":
|
||||||
message = integrate_feature(
|
message = integrate_feature(
|
||||||
state_root,
|
state_root,
|
||||||
@@ -1728,7 +1908,7 @@ def main(argv: list[str]) -> int:
|
|||||||
args.main_branch,
|
args.main_branch,
|
||||||
args.allow_partial,
|
args.allow_partial,
|
||||||
)
|
)
|
||||||
elif args.result == "resolved":
|
elif args.command == "finish" and args.result == "resolved":
|
||||||
message = finish_resolved_ticket(
|
message = finish_resolved_ticket(
|
||||||
state_root,
|
state_root,
|
||||||
Path(args.repo_root),
|
Path(args.repo_root),
|
||||||
@@ -1741,7 +1921,7 @@ def main(argv: list[str]) -> int:
|
|||||||
args.verified,
|
args.verified,
|
||||||
args.reviewed,
|
args.reviewed,
|
||||||
)
|
)
|
||||||
else:
|
elif args.command == "finish":
|
||||||
message = finish_nonresolved_ticket(
|
message = finish_nonresolved_ticket(
|
||||||
state_root,
|
state_root,
|
||||||
args.feature,
|
args.feature,
|
||||||
@@ -1750,6 +1930,8 @@ def main(argv: list[str]) -> int:
|
|||||||
args.result,
|
args.result,
|
||||||
args.reason,
|
args.reason,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
raise StateError(f"unhandled command: {args.command}")
|
||||||
except (OSError, StateError, UnicodeError) as exc:
|
except (OSError, StateError, UnicodeError) as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
return 2
|
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_START = "<!-- playbook:agents:start -->"
|
||||||
_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->"
|
_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]:
|
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")
|
agents_text = agents_path.read_text(encoding="utf-8")
|
||||||
if start_marker in agents_text:
|
if start_marker in agents_text:
|
||||||
block = preserve_agents_subblock(block, 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(
|
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)")
|
log("Updated: AGENTS.md (section)")
|
||||||
else:
|
else:
|
||||||
@@ -741,9 +754,6 @@ def sync_rules_action(config: dict, context: dict) -> int:
|
|||||||
|
|
||||||
rules_dst = project_root / "AGENT_RULES.md"
|
rules_dst = project_root / "AGENT_RULES.md"
|
||||||
force = bool(config.get("force", False))
|
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)
|
project_name = resolve_project_name(context)
|
||||||
playbook_scripts = resolve_playbook_scripts(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")
|
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||||
no_backup = bool(config.get("no_backup", False))
|
no_backup = bool(config.get("no_backup", False))
|
||||||
|
|
||||||
backup_path(rules_dst, no_backup)
|
|
||||||
text = rules_src.read_text(encoding="utf-8")
|
text = rules_src.read_text(encoding="utf-8")
|
||||||
text = replace_placeholders(
|
text = replace_placeholders(
|
||||||
text, project_name, date_value, playbook_scripts, playbook_root
|
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"
|
local_rules = project_root / "AGENT_RULES.local.md"
|
||||||
if not local_rules.exists():
|
if not local_rules.exists():
|
||||||
|
|||||||
+256
-134
@@ -1,5 +1,7 @@
|
|||||||
# AGENT_RULES
|
# AGENT_RULES
|
||||||
|
|
||||||
|
<!-- playbook:rules:start -->
|
||||||
|
|
||||||
目的:为本仓库提供稳定的 Matt Pocock 工程流程与 ticket 执行约束。
|
目的:为本仓库提供稳定的 Matt Pocock 工程流程与 ticket 执行约束。
|
||||||
|
|
||||||
`.scratch/` 是 spec、ticket、feature 队列和执行状态的唯一事实源。
|
`.scratch/` 是 spec、ticket、feature 队列和执行状态的唯一事实源。
|
||||||
@@ -45,28 +47,75 @@
|
|||||||
|
|
||||||
## 任务入口
|
## 任务入口
|
||||||
|
|
||||||
### 可直接执行
|
四个入口按成本递增排列,**从上往下取第一个满足的**。每个入口都定义了强制升级条件;条件成立
|
||||||
|
时立即停止当前路径并转入指定入口,已产生的改动只作为事实输入,不视为已批准方案。
|
||||||
|
|
||||||
只读分析、定位、审查,以及不改变行为的局部机械修改可以直接执行,不生成
|
### 入口 1:直接执行
|
||||||
spec/tickets。仍须遵守项目规则、验证实际结果,并只处理本次相关改动。
|
|
||||||
|
|
||||||
### 新 feature 或设计变更
|
只读分析、定位、审查,以及不改变可观察行为的机械修改(重命名、格式化、注释、导入整理、纯
|
||||||
|
文档措辞)。不生成 spec/tickets,不建 branch。仍须遵守项目规则、验证实际结果,并只处理本次
|
||||||
|
相关改动。
|
||||||
|
|
||||||
需要需求澄清、设计取舍、跨 session 调度、并发执行、迁移或回滚时,进入完整工程
|
**升级条件**:出现任何可观察行为变化 → 入口 2。
|
||||||
主链。边界不清时也走该入口;执行中才发现存在未确认设计时停止实现,已有改动只作为
|
|
||||||
事实输入,不视为已批准方案。
|
|
||||||
|
|
||||||
### 已明确预期行为的 bug
|
### 入口 2:单切片改动
|
||||||
|
|
||||||
已知正确行为且可以建立失败反馈回路的 bug,使用 `diagnosing-bugs` 完整执行 Phase 1-6,
|
会改变行为、不属于入口 3 的已知 bug,且同时满足以下**全部**条件的改动走这里,不入队、不建
|
||||||
由它负责反馈回路、回归测试、修复、验证和清理,不再进入 `implement` 或重复 `tdd`,也不为
|
ticket branch:
|
||||||
已确定的需求重新 grilling。若诊断暴露新的产品取舍、架构方向或缺失测试 seam,停止修复并
|
|
||||||
转入 `grill-with-docs`。若 bug 来自已领取 ticket,Phase 6 后直接继续本地 ticket 执行协议的
|
- 只涉及一个模块,或只扩展一个已存在的公开接口
|
||||||
提交、review 和 finish 门禁。
|
- 能在已存在的测试 seam 上验证,不需要新建 seam
|
||||||
|
- 不新增公开接口、配置项、数据格式或第三方依赖
|
||||||
|
- 不涉及数据迁移、回滚、兼容性窗口或并发协调
|
||||||
|
- 预计一个 session 内交付,不需要跨 session 调度
|
||||||
|
|
||||||
|
流程:在已存在 seam 上按 `tdd` 完成一个可观察行为切片 → 运行局部验证 → 提交 → 对
|
||||||
|
`HEAD` 运行 `code-review` 的 Standards 单轴 → 修完硬 finding 后重新提交和验证。证据
|
||||||
|
写入 commit message 和 session 收尾,不写 `.scratch/`。
|
||||||
|
|
||||||
|
**升级条件**(任一成立即停止并转入口 4):需要新 seam、跨出上述任一条边界、或发现
|
||||||
|
未确认的设计取舍。
|
||||||
|
|
||||||
|
### 入口 3:已明确预期行为的 bug
|
||||||
|
|
||||||
|
正确行为已知的 bug 走这里,**无论当下能否建立失败反馈回路**——建立反馈回路是
|
||||||
|
`diagnosing-bugs` 的 Phase 1,不是进入它的前提。`diagnosing-bugs` 完整执行 Phase 1-6,负责反馈回路、
|
||||||
|
复现最小化、假设、回归测试、修复、验证和清理;不另起 `tdd` 会话(Phase 5 内部已是
|
||||||
|
test-first),也不为已确定的需求重新 grilling。
|
||||||
|
|
||||||
|
两种缺口按该 skill 自身的规定处理:Phase 1 建不出可失败的命令时停下来索取环境、artifact 或
|
||||||
|
许可,不要换入口绕过;不存在正确 seam 时把"缺失 seam"本身作为发现记录,不因此中断修复,
|
||||||
|
修复落地后再建议 `improve-codebase-architecture`。诊断暴露新的产品取舍或架构方向时,先完成
|
||||||
|
Phase 5-6 让缺陷不再复现,再把取舍带入入口 4,不要把未修完的缺陷留在原地等设计结论。
|
||||||
|
|
||||||
|
若 bug 来自已领取 ticket,Phase 6 后直接继续本地 ticket 执行协议的提交、review 和 finish
|
||||||
|
门禁。
|
||||||
|
|
||||||
|
### 入口 4:新 feature 或设计变更
|
||||||
|
|
||||||
|
以下任一条成立时进入完整工程主链:
|
||||||
|
|
||||||
|
- 存在需要用户决策的产品取舍或架构方向
|
||||||
|
- 需要新的测试 seam,或新增公开接口、配置项、数据格式
|
||||||
|
- 跨两个以上模块边界
|
||||||
|
- 涉及数据迁移、回滚或兼容性窗口
|
||||||
|
- 需要跨 session 调度或多 session 并发
|
||||||
|
- 预计无法在一个 session 内交付
|
||||||
|
|
||||||
|
**边界不清时先按入口 2 起步**,触到入口 2 的任一升级条件时立即转入本入口。"不确定"的正确
|
||||||
|
处置是用最小路径试探到边界,不是预付最贵的流程。
|
||||||
|
|
||||||
|
### 非交互模式下的入口 4
|
||||||
|
|
||||||
|
主链的 grilling 收敛和 seam confirmation 需要用户在场。无人值守而判定为入口 4 时:已有已领取
|
||||||
|
ticket 就 `finish --result blocked --reason "<待确认的具体决策>"`;尚无 ticket 则不得代替用户
|
||||||
|
决策、也不得降级到入口 2 硬做。用 `to-questionnaire` 把待决问题写到项目根目录的
|
||||||
|
`.scratch/questions/<slug>.md`,记录阻塞阶段、已知上下文、待决问题和恢复入口;用户回答后从
|
||||||
|
`grill-with-docs` 恢复。不得把问题清单写入临时 worktree 或稳定知识文件。
|
||||||
|
|
||||||
## 正式工程主链
|
## 正式工程主链
|
||||||
|
|
||||||
新 feature 或设计变更使用以下顺序:
|
入口 4 使用以下顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
setup-matt-pocock-skills
|
setup-matt-pocock-skills
|
||||||
@@ -80,15 +129,18 @@ setup-matt-pocock-skills
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 每个仓库首次使用时运行 `setup-matt-pocock-skills`,本地开发选择 local markdown tracker
|
- 每个仓库首次使用时运行 `setup-matt-pocock-skills`,本地开发选择 local markdown tracker
|
||||||
- 正式仓库设计使用 `grill-with-docs`
|
|
||||||
- 进入 `grill-with-docs` 或本地 ticket 执行协议前,重新读取 `memory-bank/project-brief.md`、
|
- 进入 `grill-with-docs` 或本地 ticket 执行协议前,重新读取 `memory-bank/project-brief.md`、
|
||||||
`memory-bank/tech-context.md` 和 `memory-bank/system-patterns.md`
|
`memory-bank/tech-context.md` 和 `memory-bank/system-patterns.md`
|
||||||
- `grilling` 必须走完整 design tree,frontier 清空并经用户确认后才进入 `to-spec`
|
- `grilling` 必须走完整 design tree,**design frontier**(尚未定下的决策集合)清空并经用户
|
||||||
- `to-spec` 不重新进行已经完成的需求采访;但必须按其原始流程确认测试 seam。若
|
确认后才进入 `to-spec`。该词与调度语义里的 ticket frontier 无关
|
||||||
grilling 尚未确认 seam,必须先向用户完成 seam confirmation
|
- seam confirmation 的责任在 `to-spec` 与 `tdd`,不在 `grilling`——后者只收敛设计决策,不涉及
|
||||||
- `tdd` 不得在未经确认的 seam 上开始
|
测试 seam。`to-spec` 必须按其原始流程与用户确认 seam 并写入 spec 的 Testing Decisions;
|
||||||
|
`tdd` 不得在未经确认的 seam 上开始
|
||||||
|
- `to-spec` 不重新进行已经完成的需求采访
|
||||||
- `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by`
|
- `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by`
|
||||||
- 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue`
|
- 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue`
|
||||||
|
- `codebase-design` 是 seam、deep module 与依赖分类的词汇来源,供 `to-spec` 和 `tdd` 查阅,
|
||||||
|
不作为独立会话运行
|
||||||
|
|
||||||
## On-ramps 与 detours
|
## On-ramps 与 detours
|
||||||
|
|
||||||
@@ -101,22 +153,24 @@ setup-matt-pocock-skills
|
|||||||
|
|
||||||
## Phase boundaries
|
## Phase boundaries
|
||||||
|
|
||||||
只在阶段边界按以下顺序判断,首个满足项生效:
|
只在阶段边界判断去向,阶段中途不做这个决定。按以下顺序取首个满足项:
|
||||||
|
|
||||||
1. 下一阶段需要当前 primary source 且 smart zone 足够时,继续当前 session
|
1. 下一阶段需要当前 session 作为 primary source,或剩余 smart zone 仍够下一阶段
|
||||||
|
(约 150k tokens)时,继续当前 session
|
||||||
2. 当前上下文与下一阶段无关时,使用 `clear`
|
2. 当前上下文与下一阶段无关时,使用 `clear`
|
||||||
3. 仅在跨 harness、跨目录/仓库、交给同事或 mid-phase 分出旁支任务时使用 `handoff`
|
3. 仅在跨 harness、跨目录/仓库、交给同事或 mid-phase 分出旁支任务时使用 `handoff`
|
||||||
4. 任务可独立 AFK 完成时交给 subagent
|
4. 任务可独立 AFK 完成时交给 subagent
|
||||||
5. 其余同 harness、同目录且仍需当前上下文的情况使用 `compact`
|
5. 其余同 harness、同目录且仍需当前上下文的情况使用 `compact`,并附上下一阶段要保留
|
||||||
|
什么的指令
|
||||||
|
|
||||||
上下文过长不等于必须 `handoff`;`handoff` 解决的是可移植性。`compact` 是决策树的默认
|
上下文过长不等于必须 `handoff`;`handoff` 解决的是可移植性。`compact` 是决策树的默认落点,
|
||||||
落点,但不是第一选择。`CONTEXT.md` 和 memory-bank 都不能替代阶段上下文。
|
但不是第一选择——除 continue 以外的每个选项都把 primary source 换成 secondary source。
|
||||||
|
`CONTEXT.md` 和 memory-bank 都不能替代阶段上下文。
|
||||||
|
|
||||||
## 本地 Ticket 执行协议
|
## 本地 Ticket 执行协议
|
||||||
|
|
||||||
这是 Playbook 对 Matt 工程 skills 的调度适配层。它复用 `tdd` 和 `code-review`。
|
这是 Playbook 对 Matt 工程 skills 的调度适配层,只复用 `tdd` 和 `code-review`。尾部顺序与
|
||||||
不直接调用上游 `implement`;后者的 `code-review -> commit` 尾部顺序无法生成主循环要求的
|
上游的 `code-review -> commit` 相反:主循环要求证据绑定到固定 commit,所以先提交再 review。
|
||||||
固定 commit 证据。
|
|
||||||
|
|
||||||
对每个 claim 严格按以下顺序执行:
|
对每个 claim 严格按以下顺序执行:
|
||||||
|
|
||||||
@@ -139,12 +193,18 @@ setup-matt-pocock-skills
|
|||||||
- `.scratch/queue.md`:feature 开发与集成顺序
|
- `.scratch/queue.md`:feature 开发与集成顺序
|
||||||
- `docs/agents/*.md`:tracker、领域文档布局和 skill 配置
|
- `docs/agents/*.md`:tracker、领域文档布局和 skill 配置
|
||||||
|
|
||||||
不得手工修改 ticket 的 `Status` 或 `main-loop:ticket-state` 区块;只通过主循环变更。
|
不得手工修改 ticket 的 `Status` 或 `main-loop:ticket-state` 区块;只通过主循环变更。主循环
|
||||||
|
没有对应命令的状态组合按"卡死与恢复"处理,仍然不手工改。
|
||||||
|
|
||||||
|
`.scratch/` 是否纳入版本控制由项目决定并写入 `AGENT_RULES.local.md`:纳入则 spec、ticket 和
|
||||||
|
证据进入历史,可审计、新 clone 能接手队列,代价是状态变更产生提交噪音;排除则历史干净,
|
||||||
|
代价是状态只存在于本机磁盘、集成后审计线索消失,并发只靠"共享同一文件系统"兜住。无论哪种
|
||||||
|
都不得把两份 `.scratch/` 当成同一队列。
|
||||||
|
|
||||||
## 稳定知识维护
|
## 稳定知识维护
|
||||||
|
|
||||||
只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和
|
只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和集成
|
||||||
集成状态只由 `.scratch/` 与 `main_loop.py` 维护。
|
状态只由 `.scratch/` 与 `main_loop.py` 维护。
|
||||||
|
|
||||||
- 项目定位、边界、目标或成功定义长期变化时,更新 `project-brief.md`
|
- 项目定位、边界、目标或成功定义长期变化时,更新 `project-brief.md`
|
||||||
- 技术栈、工具链、环境差异或验证入口长期变化时,更新 `tech-context.md`;写入
|
- 技术栈、工具链、环境差异或验证入口长期变化时,更新 `tech-context.md`;写入
|
||||||
@@ -153,14 +213,13 @@ setup-matt-pocock-skills
|
|||||||
- `CONTEXT.md` 只记录稳定领域词汇和定义;不把聊天流水、未验证猜测或短期进度写入
|
- `CONTEXT.md` 只记录稳定领域词汇和定义;不把聊天流水、未验证猜测或短期进度写入
|
||||||
`CONTEXT.md`
|
`CONTEXT.md`
|
||||||
- 项目特有执行规则写入 `AGENT_RULES.local.md`
|
- 项目特有执行规则写入 `AGENT_RULES.local.md`
|
||||||
- 符合 phase-boundary 窄条件的可移植上下文由 `handoff` 写入 OS 临时目录,不写入稳定
|
- `handoff` 的产物写入 OS 临时目录,不写入稳定知识文件
|
||||||
知识文件;同 harness、同目录的续作优先按决策树选择 continue、clear 或 compact
|
|
||||||
- 不为普通实现选择创建 ADR;没有长期价值的信息时不更新这些文件
|
- 不为普通实现选择创建 ADR;没有长期价值的信息时不更新这些文件
|
||||||
|
|
||||||
## 调度语义
|
## 调度语义
|
||||||
|
|
||||||
- feature 按 `.scratch/queue.md` 顺序调度
|
- feature 按 `.scratch/queue.md` 顺序调度
|
||||||
- 同一 feature 中 blocker 全部满足的 tickets 构成 frontier,按稳定编号领取
|
- 同一 feature 中 blocker 全部满足的 tickets 构成 ticket frontier,按稳定编号领取
|
||||||
- 同一 feature 的多个 frontier tickets 可在 worktree 模式并发执行
|
- 同一 feature 的多个 frontier tickets 可在 worktree 模式并发执行
|
||||||
- 当前 feature 的 frontier 全被领取时返回 `BUSY`,不向后续 feature 扩张
|
- 当前 feature 的 frontier 全被领取时返回 `BUSY`,不向后续 feature 扩张
|
||||||
- 只有前序 feature 无 frontier、无活动 claim 且确实 blocked 时,才可开发后序 feature
|
- 只有前序 feature 无 frontier、无活动 claim 且确实 blocked 时,才可开发后序 feature
|
||||||
@@ -171,27 +230,62 @@ setup-matt-pocock-skills
|
|||||||
- 含 `skipped` ticket 的 feature 标记为 partial,集成时必须显式授权
|
- 含 `skipped` ticket 的 feature 标记为 partial,集成时必须显式授权
|
||||||
- stale 只由 heartbeat 时间派生,不自动转移 owner;只有 `reclaim` 可以接管
|
- stale 只由 heartbeat 时间派生,不自动转移 owner;只有 `reclaim` 可以接管
|
||||||
|
|
||||||
|
### 读取输出而不是只看返回码
|
||||||
|
|
||||||
|
`BUSY`、`NOOP: ...`、`RETRY: ...` 都写到 stdout 并且**返回码为 0**;只有真正的错误才是
|
||||||
|
`ERROR: <msg>` 到 stderr、返回码 2。调用方必须解析 stdout,不能用返回码判断是否拿到 ticket。
|
||||||
|
|
||||||
|
`status` 输出 `FEATURE=`、`STATE=`、`PARTIAL=`、`FRONTIER=`、`CLAIM=`、`OWNER=`、
|
||||||
|
`HEARTBEAT=`、`STALE=`、`ISOLATION=`、`WORKSPACE=`、`TICKET_ERROR=`、`BLOCKED=`、
|
||||||
|
`FEATURE_BLOCKED=`、`MAIN_INTEGRATION_COMMIT=`;队列为空时输出 `NO FEATURES`。
|
||||||
|
|
||||||
|
`STATE=active` 不代表可推进:tickets 只剩 `resolved` 与 `blocked` 的 feature 同样报
|
||||||
|
`STATE=active FRONTIER=-`,此时 `claim` 返回 `NOOP`。判断是否卡住要同时看 `FRONTIER=-` 和
|
||||||
|
是否存在 `BLOCKED=` 行。
|
||||||
|
|
||||||
|
### 心跳的实际作用
|
||||||
|
|
||||||
|
heartbeat 没有强制力:过期 claim 仍保有全部权限,原 owner 可以继续 heartbeat、`finish` 和
|
||||||
|
resolve。stale 的唯一后果是**别人获得 `reclaim` 的资格**。所以"每 10 分钟一次"是为了让接管
|
||||||
|
判断准确,不是为了保住 claim。
|
||||||
|
|
||||||
|
### 卡死与恢复
|
||||||
|
|
||||||
|
- claim 的环境准备失败时,主循环把该 ticket 写成 `blocked`、记录原因、把 `claimed_by` 设为
|
||||||
|
本次 owner,并以返回码 2 退出。一次失败的 claim 会占住这张 ticket
|
||||||
|
- `finish --result released` 只允许原 `claimed_by` 解除 blocked;原 session 已丢失时用
|
||||||
|
`release-ticket`
|
||||||
|
- `reclaim` 只接管 `claimed` 状态的 stale claim,不能用于 `blocked` ticket
|
||||||
|
- resume 与 `reclaim` 返回的 `BASE` 是 claim 当时记录的值,**不会刷新**;feature head 已推进
|
||||||
|
时直接拿它去 `finish` 会得到 `RETRY: feature advanced`,按重试流程取新的 `FEATURE_HEAD`
|
||||||
|
|
||||||
## 执行隔离
|
## 执行隔离
|
||||||
|
|
||||||
`claim --isolation` 支持:
|
`claim --isolation`:`in-place` 串行执行不建额外 worktree;`worktree` 每个 ticket 独立
|
||||||
|
branch/worktree,适用于多 session 并发;`auto` 无其他活动 claim 时用 in-place,否则用
|
||||||
|
worktree。计划并发时第一个 session 就必须指定 `worktree`。
|
||||||
|
|
||||||
- `in-place`:串行执行,不创建额外 worktree;claim 生命周期内其他 owner 得到 `BUSY`
|
`BUSY` 的作用域是**整个队列,不限于同一 feature**,来源有三个:请求 `in-place` 而队列里存在
|
||||||
- `worktree`:每个 ticket 独立 branch/worktree,适用于多 session 并发
|
任何活动 claim;队列里存在任何 `in-place` 活动 claim(此时所有 isolation 的新 claim 都
|
||||||
- `auto`:无其他活动 claim 时使用 in-place,否则使用 worktree
|
BUSY);队首可调度 feature 的 frontier 已被领完。stale claim 也算活动 claim,必须显式
|
||||||
|
`reclaim` 或 `release-ticket` 才能腾出位置。同一 owner 恢复自己的 ticket 不受这些检查影响。
|
||||||
|
|
||||||
计划并发时,第一个 session 就必须指定 `worktree`。明确不并发时可使用
|
in-place 要求 checkout 无非 `.scratch` 改动、HEAD 非 detached、目标 branch 未被其他 worktree
|
||||||
`in-place` 或 `auto`,不必创建 worktree。
|
占用。主循环不得自动 stash、reset、覆盖或丢弃改动。唯一例外:重新 claim 自己此前以 in-place
|
||||||
|
释放的同一 ticket 且 workspace/branch 都匹配时,跳过 dirty 检查以保留未提交改动。
|
||||||
|
|
||||||
in-place 模式要求 checkout 无非 `.scratch` 改动、HEAD 非 detached,且目标 branch
|
worktree 模式下若 `feature/<slug>` 正被 control checkout 占用,主循环会先确认它干净、再把
|
||||||
未被其他 worktree 占用。主循环不得自动 stash、reset、覆盖或丢弃改动。
|
control checkout 切到主干以释放该 branch。
|
||||||
|
|
||||||
`main_loop.py` 只支持 local Markdown tracker,并要求所有 sessions 共享同一文件系统、
|
`main_loop.py` 只支持 local Markdown tracker,要求所有 sessions 共享同一文件系统、control
|
||||||
control checkout 和 Git common directory。远程 tracker 可以由 Matt skills 单独使用,
|
checkout 和 Git common directory。远程 tracker 可以由 Matt skills 单独使用,但本主循环没有
|
||||||
但本主循环没有远程 tracker adapter,不能接入远程 claim/finish 状态。跨机器或独立
|
远程 tracker adapter,不能接入远程 claim/finish 状态;跨机器或独立 clone 的并发不受支持。
|
||||||
clone 的并发不受支持,不得把两份 `.scratch/` 当成同一队列。
|
|
||||||
|
|
||||||
## 主循环命令
|
## 主循环命令
|
||||||
|
|
||||||
|
主干 branch 名不是 `main` 时,`claim` 和 `integrate` 必须显式传 `--main-branch <name>`;其余
|
||||||
|
子命令不接触主干,也不需要 `--repo-root`。
|
||||||
|
|
||||||
### 入队和状态
|
### 入队和状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -202,41 +296,42 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py status \
|
|||||||
--state-root .scratch
|
--state-root .scratch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`enqueue` 对已入队 feature 幂等,返回 `EXISTS=<slug>`。
|
||||||
|
|
||||||
### 领取
|
### 领取
|
||||||
|
|
||||||
`<owner>` 必须是当前 session 全局唯一且稳定的标识,例如
|
`<owner>` 必须全局唯一且在本 session 内稳定,例如 `<agent>-<UTC timestamp>-<random>`。
|
||||||
`<agent>-<UTC timestamp>-<random suffix>`。不得在并行 session 间复用 `codex`、`claude`
|
不得在并行 session 间复用 `codex`、`claude` 等通用名称;同一 owner 重复 claim 只用于原
|
||||||
等通用名称;同一 owner 的重复 claim 只用于原 session 恢复自己的 ticket。
|
session 恢复自己的 ticket,其他 owner 不得接管,失联时用显式 reclaim。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py claim \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py claim \
|
||||||
--state-root .scratch --repo-root . \
|
--state-root .scratch --repo-root . \
|
||||||
--owner "<owner>" --isolation in-place|worktree|auto
|
--owner "<owner>" --isolation in-place|worktree|auto \
|
||||||
|
[--main-branch main]
|
||||||
```
|
```
|
||||||
|
|
||||||
stdout 返回 `FEATURE`、`TICKET`、`CONTROL_ROOT`、绝对 `STATE_ROOT`、`WORKSPACE`、
|
stdout 返回 8 个赋值行:`FEATURE`、`TICKET`;`CONTROL_ROOT` 是共享 Git control checkout;
|
||||||
`BRANCH`、`BASE` 和 `ISOLATION`:
|
绝对 `STATE_ROOT` 是唯一状态目录,不必位于 `WORKSPACE` 内;`WORKSPACE` 是当前 ticket 的代码
|
||||||
|
工作区;其余为 `BRANCH`、`BASE`、`ISOLATION`。也可能返回 `NO FEATURES`、
|
||||||
- `CONTROL_ROOT` 是共享 Git control checkout
|
`NOOP: no claimable tickets`、`BUSY`,或 `INTEGRATION_REQUIRED=<slug>`(队首 feature 已
|
||||||
- `STATE_ROOT` 是唯一状态目录;它不必位于 `WORKSPACE` 内
|
ready-to-integrate,必须先 `integrate`)。
|
||||||
- `WORKSPACE` 是当前 ticket 的代码工作区
|
|
||||||
|
|
||||||
领取成功后立即读取 `<STATE_ROOT>/<FEATURE>/spec.md` 和
|
领取成功后立即读取 `<STATE_ROOT>/<FEATURE>/spec.md` 和
|
||||||
`<STATE_ROOT>/<FEATURE>/issues/<TICKET>-*.md`;不得在 claim 前根据 frontier 猜测本 session
|
`<STATE_ROOT>/<FEATURE>/issues/<TICKET>-*.md`;不得在 claim 前根据 frontier 猜测将领取哪个
|
||||||
将领取哪个 ticket。后续 review 也必须使用这两个已领取上下文,而不是 worktree 内相对
|
ticket。后续 review 也必须用这两个已领取上下文,而不是 worktree 内相对 `.scratch` 的偶然
|
||||||
`.scratch` 的偶然副本。
|
副本。
|
||||||
|
|
||||||
实现、提交和 review 必须在返回的 `WORKSPACE`/`BRANCH` 中完成。heartbeat、reclaim、
|
实现、提交和 review 在返回的 `WORKSPACE`/`BRANCH` 中完成;其余子命令一律用
|
||||||
finish、status、block/release-feature 和 integrate 必须使用
|
`--state-root "<STATE_ROOT>"`,不得在 ticket worktree 中使用相对 `.scratch`。feature 集成
|
||||||
`--state-root "<STATE_ROOT>"`,不得在 ticket worktree 中使用相对 `.scratch`。
|
workspace 的路径不在 claim 输出里,只写在 `<STATE_ROOT>/<FEATURE>/.main-loop.json` 的
|
||||||
|
`integration_workspace`;纯 in-place 流程不创建该文件,feature 级操作在 `CONTROL_ROOT` 上做。
|
||||||
同一 owner 重复 claim 恢复原 ticket。其他 owner 不得接管;失联时使用显式 reclaim。
|
|
||||||
|
|
||||||
### 心跳和接管
|
### 心跳和接管
|
||||||
|
|
||||||
claim 的 stale 租约固定为 30 分钟,调用者不得缩短。claim 存续期间至少每 10 分钟发送一次
|
claim 的 stale 租约固定为 30 分钟,调用者不得缩短。claim 存续期间至少每 10 分钟发送一次
|
||||||
heartbeat,并在预计耗时较长的验证、构建或 review 前后各发送一次。无法继续维持 heartbeat
|
heartbeat,并在耗时较长的验证、构建或 review 前后各发送一次。无法继续维持时,根据是否需要
|
||||||
时,使用 `finish --result released|blocked` 明确交还或阻塞 ticket。
|
保留阻塞原因分别执行下文完整的 `--result released` 或 `--result blocked --reason` 命令。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py heartbeat \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py heartbeat \
|
||||||
@@ -248,35 +343,36 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py reclaim \
|
|||||||
--feature <feature> --ticket <NN> --owner "<new-owner>"
|
--feature <feature> --ticket <NN> --owner "<new-owner>"
|
||||||
```
|
```
|
||||||
|
|
||||||
reclaim 只允许接管 stale claim,并保留原 branch、worktree 和未提交改动。
|
reclaim 只接管 stale 的 `claimed` ticket,要求新 owner 与原 owner 不同,保留原 branch、
|
||||||
|
worktree 和未提交改动。被接管后原 owner 的 heartbeat 与 finish 立即报
|
||||||
|
`owned by another session`。
|
||||||
|
|
||||||
### Review 适配契约
|
### Review 适配契约
|
||||||
|
|
||||||
调用 Matt `code-review` 时不得依赖其自动搜索或交互补问,必须显式提供:
|
`code-review` 只产出 Standards 与 Spec 两段 findings,不给 pass/fail 判定,也不合并或
|
||||||
|
重排 findings。下面的 `pass` 映射是 Playbook 加的一层,由调用方把 findings 归结为判定。
|
||||||
|
|
||||||
- fixed point:ticket review 使用 claim 的 `BASE` 或重试返回的 `FEATURE_HEAD`;feature review
|
调用时不得依赖其自动搜索或交互补问,必须显式提供:
|
||||||
使用最新 `main` HEAD
|
|
||||||
- ticket review 的 Spec sources:逻辑路径 `.scratch/<feature>/spec.md` 与
|
|
||||||
`.scratch/<feature>/issues/<ticket>-*.md`,实际从绝对 `STATE_ROOT` 解析
|
|
||||||
- feature review 的 Spec sources:`.scratch/<feature>/spec.md` 和该 feature 的全部 ticket
|
|
||||||
acceptance criteria,实际从绝对 `STATE_ROOT` 解析
|
|
||||||
|
|
||||||
只有对应 axis 零个未解决的硬 finding 时才能记录 `pass`:
|
- fixed point:ticket review 用 claim 的 `BASE` 或重试返回的 `FEATURE_HEAD`;feature
|
||||||
|
review 用最新 `main` HEAD
|
||||||
|
- Spec sources:ticket review 用 `.scratch/<feature>/spec.md` 与
|
||||||
|
`.scratch/<feature>/issues/<ticket>-*.md`;feature review 用该 spec 加本 feature 全部
|
||||||
|
ticket 的 acceptance criteria。两者都从绝对 `STATE_ROOT` 解析
|
||||||
|
|
||||||
- `standards=pass`:没有未解决的仓库标准违规;baseline smell 属 judgement call,必须逐项记录
|
只有对应 axis 零个未解决的硬 finding 时才能记录 `pass`:`standards=pass` 指没有未解决的
|
||||||
已修复或带理由接受,但不会仅因被提出就自动失败
|
仓库标准违规(baseline smell 属 judgement call,须逐项记录已修复或带理由接受,不因被提出
|
||||||
- `spec=pass`:没有遗漏、部分实现、错误实现或未授权范围扩张
|
就自动失败);`spec=pass` 指没有遗漏、部分实现、错误实现或未授权范围扩张。
|
||||||
|
|
||||||
任一来源缺失、Spec axis 被跳过、review 尚在询问输入,或仍有未解决的硬 finding 时,
|
任一来源缺失、Spec axis 被跳过、review 尚在询问输入,或仍有未解决的硬 finding 时,
|
||||||
不得据此填写 `standards=pass` 或 `spec=pass`。修复会改变 `HEAD`,因此必须重新运行受影响
|
不得据此填写 `standards=pass` 或 `spec=pass`。修复会改变 `HEAD`,必须重跑受影响验证和完整双轴
|
||||||
验证和完整双轴 review,不能沿用旧报告。
|
review,不能沿用旧报告。
|
||||||
|
|
||||||
### Ticket 完成或状态转换
|
### Ticket 完成或状态转换
|
||||||
|
|
||||||
`resolved` 前必须先提交实现,再按 Review 适配契约对 `BASE...HEAD` 运行 Matt
|
`resolved` 前必须先提交实现,再按 Review 适配契约对 `BASE...HEAD` 运行 Matt
|
||||||
`code-review` 的 Standards/Spec 双轴审查。
|
`code-review` 的 Standards/Spec 双轴审查。验证证据必须包含被验证 commit,review 证据必须
|
||||||
验证证据必须包含被验证 commit,review 证据必须包含被审查 commit 和固定 base;任意
|
包含被审查 commit 和固定 base;任意非空文本或裸 `pass` 不能替代结构化证据。
|
||||||
非空文本或裸 `pass` 不能替代结构化证据。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
||||||
@@ -285,48 +381,44 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
|||||||
--result resolved \
|
--result resolved \
|
||||||
--implementation-commit <HEAD> \
|
--implementation-commit <HEAD> \
|
||||||
--feature-head <已验证feature HEAD> --review-base <同一feature HEAD> \
|
--feature-head <已验证feature HEAD> --review-base <同一feature HEAD> \
|
||||||
--verified "commit=<HEAD>; result=pass; <实际局部验证证据>" \
|
--verified "<ticket-verification.json>" \
|
||||||
--reviewed "commit=<HEAD>; base=<review-base>; standards=pass; spec=pass"
|
--reviewed "<ticket-review.json>"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
验证和 review 参数必须是下文定义的 UTF-8 JSON artifact 路径,不接受内联 `pass` 文本。
|
||||||
|
|
||||||
首次 review base 是 claim 返回的 `BASE`。feature HEAD 已推进时返回
|
首次 review base 是 claim 返回的 `BASE`。feature HEAD 已推进时返回
|
||||||
`RETRY: feature advanced` 和新的 `FEATURE_HEAD`;把该 `FEATURE_HEAD` 合入 ticket branch,
|
`RETRY: feature advanced` 和新的 `FEATURE_HEAD`;把该 `FEATURE_HEAD` 合入 ticket branch,
|
||||||
重新运行受影响验证,并以它作为新的 review base 重跑双轴 review。重试时
|
重新运行受影响验证,并以它作为新的 review base 重跑双轴 review。重试时
|
||||||
`--feature-head`、`--review-base` 和 review evidence 的 `base` 必须都使用该新值,验证与
|
`--feature-head`、`--review-base` 和 review evidence 的 `base` 都用该新值,验证与 review
|
||||||
review evidence 的 `commit` 必须等于新的 ticket `HEAD`。只有成功集成到 feature branch
|
evidence 的 `commit` 必须等于新的 ticket `HEAD`。只有成功集成到 feature branch 后 ticket
|
||||||
后 ticket 才变为 `resolved`。
|
才变为 `resolved`。
|
||||||
|
|
||||||
其他转换:
|
`finish` 不检查验收勾选框;验收由 `code-review` 的 Spec axis 负责,勾选框在 resolve 前
|
||||||
|
自行更新。
|
||||||
|
|
||||||
|
其他 `finish` 转换共用上面的 `--state-root`、`--repo-root`、`--feature`、`--ticket`、`--owner`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
||||||
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
||||||
--feature <feature> --ticket <NN> --owner "<owner>" \
|
--feature <feature> --ticket <NN> --owner "<owner>" \
|
||||||
--result blocked|skipped --reason "<明确原因>"
|
--result blocked --reason "<明确原因>"
|
||||||
|
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
|
||||||
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
||||||
--feature <feature> --ticket <NN> --owner "<owner>" \
|
--feature <feature> --ticket <NN> --owner "<owner>" \
|
||||||
--result released
|
--result released
|
||||||
|
|
||||||
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py release-ticket \
|
||||||
|
--state-root "<STATE_ROOT>" --feature <feature> --ticket <NN> \
|
||||||
|
--reason "<原 session 无法恢复的原因>"
|
||||||
```
|
```
|
||||||
|
|
||||||
release 返回 `ready-for-agent` 并保留 workspace;blocked ticket 可由原 owner release 后恢复。
|
`blocked` 和 `skipped` 都必须提供 `--reason`。`released` 回到 `ready-for-agent` 并保留 workspace,
|
||||||
|
只能由原 `claimed_by` 调用。`release-ticket` 用于原 session 已丢失、无人能 release 的 blocked
|
||||||
### Feature 集成阻塞
|
ticket;它不校验 owner,保留 branch、worktree 和未提交改动,返回
|
||||||
|
`TICKET_RELEASED=<feature>/<NN>`。
|
||||||
ready-to-integrate feature 若因 main 同步冲突或人工决策暂时不能集成,必须显式记录;
|
|
||||||
这样主循环才会继续为后序 feature 分配开发工作:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py block-feature \
|
|
||||||
--state-root "<STATE_ROOT>" --feature <feature> --reason "<明确原因>"
|
|
||||||
|
|
||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py release-feature \
|
|
||||||
--state-root "<STATE_ROOT>" --feature <feature>
|
|
||||||
```
|
|
||||||
|
|
||||||
release 后该 feature 重新成为队首 `INTEGRATION_REQUIRED`。后序 feature 即使已开发完成,
|
|
||||||
仍不得越过它集成到 main。
|
|
||||||
|
|
||||||
### Feature 顺序集成
|
### Feature 顺序集成
|
||||||
|
|
||||||
@@ -336,40 +428,71 @@ feature 必须吸收最新 `main`,完成 feature 验证、main 候选验证和
|
|||||||
python {{PLAYBOOK_SCRIPTS}}/main_loop.py integrate \
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py integrate \
|
||||||
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
|
||||||
--feature <feature> --feature-head <已验证feature HEAD> \
|
--feature <feature> --feature-head <已验证feature HEAD> \
|
||||||
--verified "commit=<feature HEAD>; result=pass; <feature级验证证据>" \
|
--verified "<feature-verification.json>" \
|
||||||
--main-verified "commit=<feature HEAD>; result=pass; <main候选验证证据>" \
|
--main-verified "<main-candidate-verification.json>" \
|
||||||
--reviewed "commit=<feature HEAD>; base=<最新main HEAD>; standards=pass; spec=pass"
|
--reviewed "<feature-review.json>"
|
||||||
```
|
```
|
||||||
|
|
||||||
partial feature 还需 `--allow-partial`。返回 `RETRY: feature needs main sync` 时,
|
partial feature 还需 `--allow-partial`;未显式授权时报错而不是静默集成。
|
||||||
先在 feature integration workspace 合并最新 main;若发生冲突,先 `block-feature`,解决后
|
|
||||||
`release-feature`,重新验证和 review 后再调用。
|
`RETRY: feature needs main sync` 表示 `main` 不是 feature head 的祖先。此时在 feature
|
||||||
|
integration workspace(纯 in-place 流程下是 `CONTROL_ROOT`)合并最新 main,重新验证和
|
||||||
|
review 后再调用。main 合并冲突时主循环会**自动**把 feature 标记为 blocked,不需要手工
|
||||||
|
`block-feature`;解决冲突后 `release-feature` 再重试。
|
||||||
|
|
||||||
|
成功后主循环会把 control checkout 切到主干,并移除干净的 ticket worktree 与
|
||||||
|
`_integration` worktree;不干净或路径异常的保留并以 `WARNING=` 行报告。已集成的 feature
|
||||||
|
再次调用时幂等返回 `INTEGRATED=`。
|
||||||
|
|
||||||
|
ready-to-integrate feature 因人工决策暂时不能集成时必须显式记录,否则主循环不会为后序
|
||||||
|
feature 继续分配开发工作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py block-feature \
|
||||||
|
--state-root "<STATE_ROOT>" --feature <feature> --reason "<明确原因>"
|
||||||
|
|
||||||
|
python {{PLAYBOOK_SCRIPTS}}/main_loop.py release-feature \
|
||||||
|
--state-root "<STATE_ROOT>" --feature <feature>
|
||||||
|
```
|
||||||
|
|
||||||
|
release 后该 feature 重新成为队首 `INTEGRATION_REQUIRED`;后序 feature 即使已开发完成,
|
||||||
|
仍不得越过它集成到 main。
|
||||||
|
|
||||||
## Git 与证据门禁
|
## Git 与证据门禁
|
||||||
|
|
||||||
1. 每个 ticket 使用 `ticket/<feature>/<NN>-<slug>` branch
|
branch 命名:ticket 用 `ticket/<feature>/<NN>-<slug>`,feature 用 `feature/<feature>`。
|
||||||
2. 每个 feature 使用 `feature/<feature>` branch
|
执行顺序见"本地 Ticket 执行协议",证据参数见"主循环命令"。
|
||||||
3. 按 TDD 完成一个可观察行为切片并运行局部验证
|
|
||||||
4. 提交全部实现,使 `HEAD` 成为可审查固定点
|
|
||||||
5. 首次以 claim 的 `BASE` 运行 `code-review`;同步推进后的 feature 时改用新的
|
|
||||||
`FEATURE_HEAD`
|
|
||||||
6. 修复 finding 后重新提交、验证和 review
|
|
||||||
7. `finish` 校验验证/review commit、review base 和 feature HEAD,再集成 ticket
|
|
||||||
8. `integrate` 把两级验证和最终 review 绑定到 feature HEAD,并把 review base 绑定到
|
|
||||||
最新 main HEAD
|
|
||||||
|
|
||||||
局部 ticket 验证、feature 验证和 main 候选验证是三个独立门禁,不能互相替代。
|
局部 ticket 验证、feature 验证和 main 候选验证是三个独立门禁,不能互相替代。
|
||||||
证据必须来自实际 fresh run;无法运行时不得伪造 `pass`。
|
|
||||||
|
`--verified`、`--main-verified` 和 `--reviewed` 必须指向 UTF-8 JSON artifact。验证 artifact
|
||||||
|
包含 `version=1`、`kind=verification`、`commit`、`result=pass`、`command`、`exit_code=0`、
|
||||||
|
`output` 和 `output_sha256`;review artifact 包含 `version=1`、`kind=review`、`commit`、`base`、
|
||||||
|
`standards=pass`、`spec=pass`、`report` 和 `report_sha256`。输出和报告必须先移除 secret;摘要是
|
||||||
|
对应 UTF-8 文本的 SHA-256。
|
||||||
|
|
||||||
|
门禁校验每个 commit 真实存在、artifact schema 与摘要、implementation/verification/review
|
||||||
|
commit 与目标 branch tip 一致、review base 正确、workspace 干净且在自己的 branch 上。成功后
|
||||||
|
把规范化 artifact 快照到 `.scratch/<feature>/evidence/`,并在 ticket 或 feature 状态中记录快照
|
||||||
|
路径和 SHA-256;原始 artifact 删除后仍可审计。
|
||||||
|
|
||||||
|
artifact 能阻止空文本、字段缺失、摘要篡改和 Git 上下文错配,但无法证明命令真的执行过,也
|
||||||
|
无法证明 report 来自真实 review。证据必须来自实际 fresh run,无法运行时不得伪造 `pass`;
|
||||||
|
需要机器强制时由 CI 或 pre-commit hook 生成 artifact。
|
||||||
|
|
||||||
## 辅助能力
|
## 辅助能力
|
||||||
|
|
||||||
以下能力在对应阶段内按需使用,不替代上面的入口路由和证据门禁:
|
以下能力在对应阶段内按需使用,不替代上面的入口路由和证据门禁:
|
||||||
|
|
||||||
| Skill | 触发条件 |
|
| Skill | 触发条件 |
|
||||||
| ---------------- | ----------------------------------- |
|
| ------------------------------- | -------------------------------------------- |
|
||||||
| `codebase-recon` | 架构、跨模块、重构、迁移或风险不明 |
|
| `codebase-recon` | 架构、跨模块、重构、迁移或风险不明 |
|
||||||
| `brooks-audit` | 架构边界和长期维护性审查 |
|
| `brooks-audit` | 架构边界和长期维护性审查 |
|
||||||
| `commit-message` | 需要检查 staged diff 或生成提交信息 |
|
| `codebase-design` | 需要 seam、deep module、依赖分类的词汇与判据 |
|
||||||
|
| `improve-codebase-architecture` | 修复落地后暴露出的结构问题 |
|
||||||
|
| `resolving-merge-conflicts` | feature 吸收 main 或集成时出现冲突 |
|
||||||
|
| `to-questionnaire` | 需要把待决问题整理成清单交回用户 |
|
||||||
|
| `commit-message` | 需要检查 staged diff 或生成提交信息 |
|
||||||
|
|
||||||
## 需要确认的场景
|
## 需要确认的场景
|
||||||
|
|
||||||
@@ -379,19 +502,18 @@ partial feature 还需 `--allow-partial`。返回 `RETRY: feature needs main syn
|
|||||||
- 需要 spec 未授权的行为、兼容性或架构取舍
|
- 需要 spec 未授权的行为、兼容性或架构取舍
|
||||||
- 需要破坏性操作、覆盖他人改动或扩大任务范围
|
- 需要破坏性操作、覆盖他人改动或扩大任务范围
|
||||||
|
|
||||||
已明确预期行为的 bug 按 `diagnosing-bugs` 路由推进;符合既有模式的小改动、测试补齐和
|
入口 1、入口 2 范围内的改动和已批准 ticket 范围内的实现不重复需求采访。无交互模式按
|
||||||
已批准 ticket 范围内的实现不重复需求采访。
|
"非交互模式下的入口 4"处理,不得替用户作设计决策。
|
||||||
无交互模式下不得替用户作设计决策;将 ticket 标记 blocked 并记录原因。
|
|
||||||
|
|
||||||
## Session 收尾
|
## Session 收尾
|
||||||
|
|
||||||
- 运行与声明相匹配的 fresh verification
|
- 运行与声明相匹配的 fresh verification
|
||||||
- ticket 执行必须通过 `finish` 写回;不得手工改状态
|
|
||||||
- 列出已完成、未完成、验证证据、风险和下一步
|
- 列出已完成、未完成、验证证据、风险和下一步
|
||||||
- 只提交当前 ticket/feature 相关改动,不混入其他 session 差异
|
- 只提交当前 ticket/feature 相关改动,不混入其他 session 差异
|
||||||
- 工作未结束时在阶段边界按 Continue -> clear -> handoff -> subagent -> compact 的顺序选择
|
- 工作未结束时按 Phase boundaries 决策树选择去向
|
||||||
去向;不要仅因上下文过长就创建 handoff
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新**:{{DATE}}
|
**最后更新**:{{DATE}}
|
||||||
|
|
||||||
|
<!-- playbook:rules:end -->
|
||||||
|
|||||||
+17
-5
@@ -42,13 +42,15 @@ templates/
|
|||||||
- 更新标记区块时保留区块外的项目内容
|
- 更新标记区块时保留区块外的项目内容
|
||||||
- 已有的人工入口和项目补充说明继续由项目维护
|
- 已有的人工入口和项目补充说明继续由项目维护
|
||||||
|
|
||||||
### 2. 初始化后由项目维护
|
### 2. 流程由 Playbook 维护,补充由项目维护
|
||||||
|
|
||||||
- `AGENT_RULES.md`:项目采用的完整工程流程与执行规则
|
- `AGENT_RULES.md`:`<!-- playbook:rules:start/end -->` 区块内的工程流程由 Playbook
|
||||||
|
维护,重新同步时刷新;项目自己的补充写在区块外,同步不会动它
|
||||||
- `AGENT_RULES.local.md`:项目私有规则,Playbook 不覆盖
|
- `AGENT_RULES.local.md`:项目私有规则,Playbook 不覆盖
|
||||||
- `memory-bank/`:稳定项目定位、技术上下文和当前系统模式,不承载机器状态
|
- `memory-bank/`:稳定项目定位、技术上下文和当前系统模式,不承载机器状态
|
||||||
|
|
||||||
Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*` 不会被删除。
|
Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*` 不会被删除。
|
||||||
|
没有标记区块的旧 `AGENT_RULES.md` 保持原样,不会被静默改写。
|
||||||
|
|
||||||
### 3. 参考模板
|
### 3. 参考模板
|
||||||
|
|
||||||
@@ -72,15 +74,23 @@ heartbeat、ticket 或集成状态;机器状态只以 `.scratch/` 和 `main_lo
|
|||||||
|
|
||||||
### AGENT_RULES.template.md
|
### AGENT_RULES.template.md
|
||||||
|
|
||||||
部署为项目的 `AGENT_RULES.md`,是 Matt Pocock 工程主链、ticket 调度、worktree、
|
部署为项目的 `AGENT_RULES.md`,是任务入口路由、Matt Pocock 工程主链、ticket 调度、
|
||||||
验证和集成协议的唯一流程权威。项目私有规则写入 `AGENT_RULES.local.md`;该文件由
|
worktree、验证和集成协议的唯一流程权威。`<!-- playbook:rules:start/end -->` 区块内的
|
||||||
项目维护,Playbook 不覆盖。
|
内容由 Playbook 维护并在重新同步时刷新,项目补充写在区块外。项目私有规则写入
|
||||||
|
`AGENT_RULES.local.md`;该文件由项目维护,Playbook 不覆盖。
|
||||||
|
|
||||||
### AGENTS.template.md
|
### AGENTS.template.md
|
||||||
|
|
||||||
部署为项目的 `AGENTS.md`,只提供语言规则、核心规则和工程上下文导航。
|
部署为项目的 `AGENTS.md`,只提供语言规则、核心规则和工程上下文导航。
|
||||||
标记区块的更新约束见下文“AGENTS 模板标记”。
|
标记区块的更新约束见下文“AGENTS 模板标记”。
|
||||||
|
|
||||||
|
## 任务入口
|
||||||
|
|
||||||
|
四个入口按成本递增,取第一个满足的:直接执行(零行为变更)、单切片改动(单模块、
|
||||||
|
已有 seam、一个 session 内)、已明确预期行为的 bug(`diagnosing-bugs` Phase 1-6)、
|
||||||
|
新 feature 或设计变更(完整主链)。边界不清时从单切片改动起步,触到升级条件再转入
|
||||||
|
完整主链,不要预付最重的流程。判据与升级条件见 `AGENT_RULES.template.md`。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -101,6 +111,8 @@ local markdown tracker。
|
|||||||
|
|
||||||
## 正式开发流程
|
## 正式开发流程
|
||||||
|
|
||||||
|
只有第四个入口(新 feature 或设计变更)走这条链;前三个入口不入队、不建 ticket branch。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
setup-matt-pocock-skills
|
setup-matt-pocock-skills
|
||||||
-> grill-with-docs
|
-> grill-with-docs
|
||||||
|
|||||||
+265
-21
@@ -1,4 +1,6 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -52,12 +54,60 @@ def parse_assignments(output: str) -> dict[str, str]:
|
|||||||
return dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
|
return dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
|
||||||
|
|
||||||
|
|
||||||
def verification_evidence(commit: str, detail: str) -> str:
|
EVIDENCE_TMP = tempfile.TemporaryDirectory()
|
||||||
return f"commit={commit}; result=pass; {detail}"
|
EVIDENCE_ROOT = Path(EVIDENCE_TMP.name)
|
||||||
|
|
||||||
|
|
||||||
def review_evidence(commit: str, base: str) -> str:
|
def write_evidence_artifact(data: dict[str, object]) -> str:
|
||||||
return f"commit={commit}; base={base}; standards=pass; spec=pass"
|
path = EVIDENCE_ROOT / f"evidence-{len(tuple(EVIDENCE_ROOT.iterdir())):04d}.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def verification_evidence(
|
||||||
|
commit: str,
|
||||||
|
detail: str,
|
||||||
|
*,
|
||||||
|
overrides: dict[str, object] | None = None,
|
||||||
|
) -> str:
|
||||||
|
output = f"{detail}: passed"
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"version": 1,
|
||||||
|
"kind": "verification",
|
||||||
|
"commit": commit,
|
||||||
|
"result": "pass",
|
||||||
|
"command": detail,
|
||||||
|
"exit_code": 0,
|
||||||
|
"output": output,
|
||||||
|
"output_sha256": hashlib.sha256(output.encode("utf-8")).hexdigest(),
|
||||||
|
}
|
||||||
|
data.update(overrides or {})
|
||||||
|
return write_evidence_artifact(data)
|
||||||
|
|
||||||
|
|
||||||
|
def review_evidence(
|
||||||
|
commit: str,
|
||||||
|
base: str,
|
||||||
|
*,
|
||||||
|
overrides: dict[str, object] | None = None,
|
||||||
|
) -> str:
|
||||||
|
report = "Standards and spec have no unresolved hard findings"
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"version": 1,
|
||||||
|
"kind": "review",
|
||||||
|
"commit": commit,
|
||||||
|
"base": base,
|
||||||
|
"standards": "pass",
|
||||||
|
"spec": "pass",
|
||||||
|
"report": report,
|
||||||
|
"report_sha256": hashlib.sha256(report.encode("utf-8")).hexdigest(),
|
||||||
|
}
|
||||||
|
data.update(overrides or {})
|
||||||
|
return write_evidence_artifact(data)
|
||||||
|
|
||||||
|
|
||||||
def init_repo(root: Path) -> None:
|
def init_repo(root: Path) -> None:
|
||||||
@@ -131,6 +181,9 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
"finish",
|
"finish",
|
||||||
"heartbeat",
|
"heartbeat",
|
||||||
"reclaim",
|
"reclaim",
|
||||||
|
"block-feature",
|
||||||
|
"release-feature",
|
||||||
|
"release-ticket",
|
||||||
"integrate",
|
"integrate",
|
||||||
):
|
):
|
||||||
self.assertIn(command, result.stdout)
|
self.assertIn(command, result.stdout)
|
||||||
@@ -925,6 +978,154 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
self.assertIn("FEATURE=alpha STATE=active PARTIAL=yes", final_status.stdout)
|
self.assertIn("FEATURE=alpha STATE=active PARTIAL=yes", final_status.stdout)
|
||||||
self.assertIn("FRONTIER=02", final_status.stdout)
|
self.assertIn("FRONTIER=02", final_status.stdout)
|
||||||
|
|
||||||
|
def test_release_ticket_recovers_a_blocked_ticket_without_its_owner(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
root = Path(tmp_dir)
|
||||||
|
init_repo(root)
|
||||||
|
issues = write_feature(root, "alpha")
|
||||||
|
write_ticket(issues, "01", "first", "First")
|
||||||
|
enqueue = run_cli(
|
||||||
|
"enqueue", "--state-root", ".scratch", "--feature", "alpha", cwd=root
|
||||||
|
)
|
||||||
|
self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr)
|
||||||
|
claim = run_cli(
|
||||||
|
"claim",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--repo-root",
|
||||||
|
".",
|
||||||
|
"--owner",
|
||||||
|
"lost-session",
|
||||||
|
"--isolation",
|
||||||
|
"worktree",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
self.assertEqual(claim.returncode, 0, msg=claim.stderr)
|
||||||
|
original = parse_assignments(claim.stdout)
|
||||||
|
blocked = run_cli(
|
||||||
|
"finish",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--repo-root",
|
||||||
|
".",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--owner",
|
||||||
|
"lost-session",
|
||||||
|
"--result",
|
||||||
|
"blocked",
|
||||||
|
"--reason",
|
||||||
|
"needs a product decision",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked.returncode, 0, msg=blocked.stderr)
|
||||||
|
|
||||||
|
foreign_release = run_cli(
|
||||||
|
"finish",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--repo-root",
|
||||||
|
".",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--owner",
|
||||||
|
"new-session",
|
||||||
|
"--result",
|
||||||
|
"released",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
foreign_reclaim = run_cli(
|
||||||
|
"reclaim",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--repo-root",
|
||||||
|
".",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--owner",
|
||||||
|
"new-session",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
missing_reason = run_cli(
|
||||||
|
"release-ticket",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--reason",
|
||||||
|
" ",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
recovered = run_cli(
|
||||||
|
"release-ticket",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--reason",
|
||||||
|
"original session is gone",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
not_blocked_again = run_cli(
|
||||||
|
"release-ticket",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--feature",
|
||||||
|
"alpha",
|
||||||
|
"--ticket",
|
||||||
|
"01",
|
||||||
|
"--reason",
|
||||||
|
"already released",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
reclaimed = run_cli(
|
||||||
|
"claim",
|
||||||
|
"--state-root",
|
||||||
|
".scratch",
|
||||||
|
"--repo-root",
|
||||||
|
".",
|
||||||
|
"--owner",
|
||||||
|
"new-session",
|
||||||
|
"--isolation",
|
||||||
|
"worktree",
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(foreign_release.returncode, 2)
|
||||||
|
self.assertIn("owned by another session", foreign_release.stderr)
|
||||||
|
self.assertEqual(foreign_reclaim.returncode, 2)
|
||||||
|
self.assertIn("is not claimed", foreign_reclaim.stderr)
|
||||||
|
self.assertEqual(missing_reason.returncode, 2)
|
||||||
|
self.assertIn("requires a reason", missing_reason.stderr)
|
||||||
|
self.assertEqual(recovered.returncode, 0, msg=recovered.stderr)
|
||||||
|
self.assertEqual(recovered.stdout.strip(), "TICKET_RELEASED=alpha/01")
|
||||||
|
self.assertEqual(not_blocked_again.returncode, 2)
|
||||||
|
self.assertIn("is not blocked", not_blocked_again.stderr)
|
||||||
|
self.assertEqual(reclaimed.returncode, 0, msg=reclaimed.stderr)
|
||||||
|
resumed = parse_assignments(reclaimed.stdout)
|
||||||
|
self.assertEqual(resumed["TICKET"], "01")
|
||||||
|
self.assertEqual(resumed["WORKSPACE"], original["WORKSPACE"])
|
||||||
|
self.assertEqual(resumed["BRANCH"], original["BRANCH"])
|
||||||
|
|
||||||
|
ticket = MAIN_LOOP.load_feature(root / ".scratch", "alpha").tickets["01"]
|
||||||
|
self.assertEqual(ticket.metadata["last_owner"], "lost-session")
|
||||||
|
self.assertEqual(
|
||||||
|
ticket.metadata["released_reason"], "original session is gone"
|
||||||
|
)
|
||||||
|
self.assertNotIn("blocked_reason", ticket.metadata)
|
||||||
|
events = [entry["event"] for entry in ticket.metadata["history"]]
|
||||||
|
self.assertIn("ticket-released", events)
|
||||||
|
|
||||||
def test_parallel_processes_claim_distinct_frontier_tickets(self):
|
def test_parallel_processes_claim_distinct_frontier_tickets(self):
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
self.assertIsNotNone(MAIN_LOOP.msvcrt)
|
self.assertIsNotNone(MAIN_LOOP.msvcrt)
|
||||||
@@ -1953,7 +2154,11 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
"--review-base",
|
"--review-base",
|
||||||
context["BASE"],
|
context["BASE"],
|
||||||
"--verified",
|
"--verified",
|
||||||
"result=pass; ticket tests",
|
verification_evidence(
|
||||||
|
implementation_commit,
|
||||||
|
"ticket tests",
|
||||||
|
overrides={"commit": ""},
|
||||||
|
),
|
||||||
"--reviewed",
|
"--reviewed",
|
||||||
review_evidence(implementation_commit, context["BASE"]),
|
review_evidence(implementation_commit, context["BASE"]),
|
||||||
cwd=root,
|
cwd=root,
|
||||||
@@ -1979,9 +2184,10 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
"--review-base",
|
"--review-base",
|
||||||
context["BASE"],
|
context["BASE"],
|
||||||
"--verified",
|
"--verified",
|
||||||
(
|
verification_evidence(
|
||||||
f"commit={implementation_commit}; result=fail; "
|
implementation_commit,
|
||||||
"result=pass; ticket tests"
|
"ticket tests",
|
||||||
|
overrides={"output_sha256": "0" * 64},
|
||||||
),
|
),
|
||||||
"--reviewed",
|
"--reviewed",
|
||||||
review_evidence(implementation_commit, context["BASE"]),
|
review_evidence(implementation_commit, context["BASE"]),
|
||||||
@@ -1990,10 +2196,13 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
status = run_cli("status", "--state-root", ".scratch", cwd=root)
|
status = run_cli("status", "--state-root", ".scratch", cwd=root)
|
||||||
|
|
||||||
self.assertEqual(finish.returncode, 2)
|
self.assertEqual(finish.returncode, 2)
|
||||||
self.assertIn("verification evidence must include commit", finish.stderr)
|
self.assertIn(
|
||||||
|
"verification evidence artifact field commit must be a non-empty string",
|
||||||
|
finish.stderr,
|
||||||
|
)
|
||||||
self.assertEqual(conflicting.returncode, 2)
|
self.assertEqual(conflicting.returncode, 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"verification evidence must include exactly one result",
|
"verification evidence artifact output_sha256 mismatch",
|
||||||
conflicting.stderr,
|
conflicting.stderr,
|
||||||
)
|
)
|
||||||
self.assertIn("CLAIM=alpha/01", status.stdout)
|
self.assertIn("CLAIM=alpha/01", status.stdout)
|
||||||
@@ -2349,9 +2558,10 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
"--verified",
|
"--verified",
|
||||||
verification_evidence(implementation_commit, "ticket tests"),
|
verification_evidence(implementation_commit, "ticket tests"),
|
||||||
"--reviewed",
|
"--reviewed",
|
||||||
(
|
review_evidence(
|
||||||
f"commit={implementation_commit}; base={context['BASE']}; "
|
implementation_commit,
|
||||||
"standards=pass"
|
context["BASE"],
|
||||||
|
overrides={"spec": ""},
|
||||||
),
|
),
|
||||||
cwd=root,
|
cwd=root,
|
||||||
)
|
)
|
||||||
@@ -2365,12 +2575,23 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(missing_ticket_verification.returncode, 2)
|
self.assertEqual(missing_ticket_verification.returncode, 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"verification evidence is required",
|
"verification evidence artifact is required",
|
||||||
missing_ticket_verification.stderr,
|
missing_ticket_verification.stderr,
|
||||||
)
|
)
|
||||||
self.assertEqual(incomplete_ticket_review.returncode, 2)
|
self.assertEqual(incomplete_ticket_review.returncode, 2)
|
||||||
self.assertIn("spec=pass", incomplete_ticket_review.stderr)
|
self.assertIn(
|
||||||
|
"review evidence artifact field spec must be a non-empty string",
|
||||||
|
incomplete_ticket_review.stderr,
|
||||||
|
)
|
||||||
self.assertEqual(resolved.returncode, 0, msg=resolved.stderr)
|
self.assertEqual(resolved.returncode, 0, msg=resolved.stderr)
|
||||||
|
ticket = MAIN_LOOP.load_feature(root / ".scratch", "alpha").tickets["01"]
|
||||||
|
ticket_evidence = ticket.metadata["evidence"]
|
||||||
|
self.assertEqual(set(ticket_evidence), {"verification", "review"})
|
||||||
|
for record in ticket_evidence.values():
|
||||||
|
self.assertRegex(record["sha256"], r"^[0-9a-f]{64}$")
|
||||||
|
self.assertTrue(
|
||||||
|
(root / ".scratch" / "alpha" / record["path"]).is_file()
|
||||||
|
)
|
||||||
|
|
||||||
feature_head = run_git(root, "rev-parse", "feature/alpha").stdout.strip()
|
feature_head = run_git(root, "rev-parse", "feature/alpha").stdout.strip()
|
||||||
main_before = run_git(root, "rev-parse", "main").stdout.strip()
|
main_before = run_git(root, "rev-parse", "main").stdout.strip()
|
||||||
@@ -2412,13 +2633,21 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
"--main-verified",
|
"--main-verified",
|
||||||
verification_evidence(feature_head, "main candidate tests"),
|
verification_evidence(feature_head, "main candidate tests"),
|
||||||
"--reviewed",
|
"--reviewed",
|
||||||
f"commit={feature_head}; base={main_before}; standards=pass",
|
review_evidence(
|
||||||
|
feature_head,
|
||||||
|
main_before,
|
||||||
|
overrides={"spec": ""},
|
||||||
|
),
|
||||||
cwd=root,
|
cwd=root,
|
||||||
)
|
)
|
||||||
unbound_feature_verification = run_cli(
|
unbound_feature_verification = run_cli(
|
||||||
*integrate_args,
|
*integrate_args,
|
||||||
"--verified",
|
"--verified",
|
||||||
"result=pass; feature tests",
|
verification_evidence(
|
||||||
|
feature_head,
|
||||||
|
"feature tests",
|
||||||
|
overrides={"commit": ""},
|
||||||
|
),
|
||||||
"--main-verified",
|
"--main-verified",
|
||||||
verification_evidence(feature_head, "main candidate tests"),
|
verification_evidence(feature_head, "main candidate tests"),
|
||||||
"--reviewed",
|
"--reviewed",
|
||||||
@@ -2437,19 +2666,22 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(missing_feature_verification.returncode, 2)
|
self.assertEqual(missing_feature_verification.returncode, 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"feature verification evidence is required",
|
"feature verification evidence artifact is required",
|
||||||
missing_feature_verification.stderr,
|
missing_feature_verification.stderr,
|
||||||
)
|
)
|
||||||
self.assertEqual(missing_main_verification.returncode, 2)
|
self.assertEqual(missing_main_verification.returncode, 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"main candidate verification evidence is required",
|
"main candidate verification evidence artifact is required",
|
||||||
missing_main_verification.stderr,
|
missing_main_verification.stderr,
|
||||||
)
|
)
|
||||||
self.assertEqual(incomplete_final_review.returncode, 2)
|
self.assertEqual(incomplete_final_review.returncode, 2)
|
||||||
self.assertIn("spec=pass", incomplete_final_review.stderr)
|
self.assertIn(
|
||||||
|
"review evidence artifact field spec must be a non-empty string",
|
||||||
|
incomplete_final_review.stderr,
|
||||||
|
)
|
||||||
self.assertEqual(unbound_feature_verification.returncode, 2)
|
self.assertEqual(unbound_feature_verification.returncode, 2)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"feature verification evidence must include commit",
|
"feature verification evidence artifact field commit must be a non-empty string",
|
||||||
unbound_feature_verification.stderr,
|
unbound_feature_verification.stderr,
|
||||||
)
|
)
|
||||||
self.assertEqual(wrong_final_review_base.returncode, 2)
|
self.assertEqual(wrong_final_review_base.returncode, 2)
|
||||||
@@ -2478,6 +2710,18 @@ class MainLoopCliTests(unittest.TestCase):
|
|||||||
self.assertIn("INTEGRATED=alpha", integrated.stdout)
|
self.assertIn("INTEGRATED=alpha", integrated.stdout)
|
||||||
self.assertIn("FEATURE=alpha STATE=integrated PARTIAL=no", status.stdout)
|
self.assertIn("FEATURE=alpha STATE=integrated PARTIAL=no", status.stdout)
|
||||||
self.assertTrue((root / "feature.txt").is_file())
|
self.assertTrue((root / "feature.txt").is_file())
|
||||||
|
feature_state = MAIN_LOOP.load_feature_state(
|
||||||
|
MAIN_LOOP.load_feature(root / ".scratch", "alpha")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
set(feature_state["evidence"]),
|
||||||
|
{"feature_verification", "main_candidate_verification", "review"},
|
||||||
|
)
|
||||||
|
for record in feature_state["evidence"].values():
|
||||||
|
self.assertRegex(record["sha256"], r"^[0-9a-f]{64}$")
|
||||||
|
self.assertTrue(
|
||||||
|
(root / ".scratch" / "alpha" / record["path"]).is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -400,6 +400,81 @@ no_backup = true
|
|||||||
self.assertIn("`.agents/tsl/index.md`", after)
|
self.assertIn("`.agents/tsl/index.md`", after)
|
||||||
self.assertIn("`.agents/markdown/index.md`", after)
|
self.assertIn("`.agents/markdown/index.md`", after)
|
||||||
|
|
||||||
|
def test_resync_refreshes_the_rules_block_and_keeps_project_additions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
project_root = Path(tmp_dir) / "project"
|
||||||
|
project_root.mkdir()
|
||||||
|
playbook_root = MODE_ROOTS["snapshot"]
|
||||||
|
config = write_config(project_root, "snapshot", playbook_root)
|
||||||
|
|
||||||
|
seed = run_playbook(SCRIPT, config, project_root)
|
||||||
|
self.assertEqual(
|
||||||
|
seed.returncode, 0, msg=f"{seed.stdout}{seed.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rules_md = project_root / "AGENT_RULES.md"
|
||||||
|
seeded = rules_md.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("<!-- playbook:rules:start -->", seeded)
|
||||||
|
self.assertIn("<!-- playbook:rules:end -->", seeded)
|
||||||
|
|
||||||
|
# A project appendix outside the block, and drift inside it.
|
||||||
|
appendix = "\n## 项目补充\n\n保留这段项目自己的说明。\n"
|
||||||
|
drifted = seeded.replace("## 任务入口", "## 任务入口(本地改过)") + appendix
|
||||||
|
rules_md.write_text(drifted, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
resync = run_playbook(SCRIPT, config, project_root)
|
||||||
|
self.assertEqual(
|
||||||
|
resync.returncode, 0, msg=f"{resync.stdout}{resync.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
after = rules_md.read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"保留这段项目自己的说明。",
|
||||||
|
after,
|
||||||
|
msg="content outside the block belongs to the project",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"## 任务入口\n",
|
||||||
|
after,
|
||||||
|
msg="the process itself is playbook-owned and must be refreshed",
|
||||||
|
)
|
||||||
|
self.assertNotIn("## 任务入口(本地改过)", after)
|
||||||
|
self.assertEqual(after.count("<!-- playbook:rules:start -->"), 1)
|
||||||
|
|
||||||
|
legacy = project_root / "legacy" / "AGENT_RULES.md"
|
||||||
|
legacy.parent.mkdir()
|
||||||
|
legacy.write_text(
|
||||||
|
"# AGENT_RULES\n\n没有 marker 的旧文件。\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
legacy_config = project_root / "playbook-legacy.toml"
|
||||||
|
legacy_config.write_text(
|
||||||
|
f"""
|
||||||
|
[playbook]
|
||||||
|
project_root = "legacy"
|
||||||
|
playbook_root = "{playbook_root.as_posix()}"
|
||||||
|
install_mode = "snapshot"
|
||||||
|
|
||||||
|
[sync_rules]
|
||||||
|
date = "2026-01-01"
|
||||||
|
no_backup = true
|
||||||
|
""".lstrip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
legacy_run = run_playbook(SCRIPT, legacy_config, project_root)
|
||||||
|
self.assertEqual(
|
||||||
|
legacy_run.returncode,
|
||||||
|
0,
|
||||||
|
msg=f"{legacy_run.stdout}{legacy_run.stderr}",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
legacy.read_text(encoding="utf-8"),
|
||||||
|
"# AGENT_RULES\n\n没有 marker 的旧文件。\n",
|
||||||
|
msg="a file predating the markers must not be rewritten silently",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+379
-92
@@ -1,9 +1,97 @@
|
|||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
TEMPLATES = ROOT / "templates"
|
TEMPLATES = ROOT / "templates"
|
||||||
|
|
||||||
|
_MAIN_LOOP_SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"playbook_main_loop_contracts", ROOT / "scripts" / "main_loop.py"
|
||||||
|
)
|
||||||
|
assert _MAIN_LOOP_SPEC and _MAIN_LOOP_SPEC.loader
|
||||||
|
MAIN_LOOP = importlib.util.module_from_spec(_MAIN_LOOP_SPEC)
|
||||||
|
sys.modules[_MAIN_LOOP_SPEC.name] = MAIN_LOOP
|
||||||
|
_MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP)
|
||||||
|
|
||||||
|
|
||||||
|
def subcommand_parsers() -> dict[str, argparse.ArgumentParser]:
|
||||||
|
parser = MAIN_LOOP.build_parser()
|
||||||
|
for action in parser._subparsers._group_actions: # noqa: SLF001
|
||||||
|
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
|
||||||
|
return dict(action.choices)
|
||||||
|
raise AssertionError("main_loop.py exposes no subcommands")
|
||||||
|
|
||||||
|
|
||||||
|
def required_flags(parser: argparse.ArgumentParser) -> set[str]:
|
||||||
|
return {
|
||||||
|
option
|
||||||
|
for action in parser._actions # noqa: SLF001
|
||||||
|
if action.required
|
||||||
|
for option in action.option_strings
|
||||||
|
if option.startswith("--")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def isolation_choices() -> set[str]:
|
||||||
|
claim = subcommand_parsers()["claim"]
|
||||||
|
for action in claim._actions: # noqa: SLF001
|
||||||
|
if "--isolation" in action.option_strings:
|
||||||
|
return set(action.choices or ())
|
||||||
|
raise AssertionError("claim exposes no --isolation choices")
|
||||||
|
|
||||||
|
|
||||||
|
def installed_skills() -> set[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
for base in (ROOT / "skills", ROOT / "skills" / "thirdparty"):
|
||||||
|
if not base.is_dir():
|
||||||
|
continue
|
||||||
|
for path in base.iterdir():
|
||||||
|
if path.is_dir() and (path / "SKILL.md").is_file():
|
||||||
|
names.add(path.name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def rules_text() -> str:
|
||||||
|
return (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def section(text: str, heading: str, until: str) -> str:
|
||||||
|
return text.split(heading, 1)[1].split(until, 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def headings(text: str) -> list[str]:
|
||||||
|
return re.findall(r"^#{2,3} (.+)$", text, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
# Tokens written in backticks that name something other than a skill. Machine
|
||||||
|
# state, CLI vocabulary and harness commands are derived from the implementation
|
||||||
|
# where possible so the allowlist stays small and reviewable.
|
||||||
|
NON_SKILL_BACKTICK_TOKENS = frozenset(
|
||||||
|
{
|
||||||
|
"base",
|
||||||
|
"claude",
|
||||||
|
"clear",
|
||||||
|
"codex",
|
||||||
|
"command",
|
||||||
|
"commit",
|
||||||
|
"compact",
|
||||||
|
"main",
|
||||||
|
"pass",
|
||||||
|
"pass-with-notes",
|
||||||
|
"passed",
|
||||||
|
"output",
|
||||||
|
"result",
|
||||||
|
"released",
|
||||||
|
"report",
|
||||||
|
"spec",
|
||||||
|
"standards",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
LEGACY_FLOW_TERMS = (
|
LEGACY_FLOW_TERMS = (
|
||||||
"using-superpowers",
|
"using-superpowers",
|
||||||
"brainstorming",
|
"brainstorming",
|
||||||
@@ -103,14 +191,17 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_templates_readme_separates_ownership_from_deployment_config(self):
|
def test_templates_readme_separates_ownership_from_deployment_config(self):
|
||||||
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
|
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
|
||||||
classification = templates_readme.split("## 模板分类", 1)[1].split(
|
classification = section(templates_readme, "## 模板分类", "## 模板说明")
|
||||||
"## 模板说明", 1
|
categories = [h for h in headings(classification) if h[:2] in ("1.", "2.", "3.")]
|
||||||
)[0]
|
|
||||||
|
|
||||||
self.assertIn("### 1. 入口导航", classification)
|
self.assertEqual(len(categories), 3, msg=f"unexpected categories: {categories}")
|
||||||
self.assertIn("### 2. 初始化后由项目维护", classification)
|
|
||||||
self.assertIn("### 3. 参考模板", classification)
|
|
||||||
self.assertIn("项目新增的 `memory-bank/*`", classification)
|
self.assertIn("项目新增的 `memory-bank/*`", classification)
|
||||||
|
self.assertIn(
|
||||||
|
"playbook:rules:start/end",
|
||||||
|
classification,
|
||||||
|
msg="ownership of AGENT_RULES.md is split by the marker block; the "
|
||||||
|
"classification has to say which side the project owns",
|
||||||
|
)
|
||||||
self.assertNotIn("[sync_", classification)
|
self.assertNotIn("[sync_", classification)
|
||||||
self.assertNotIn("force", classification)
|
self.assertNotIn("force", classification)
|
||||||
self.assertNotIn("no_backup", classification)
|
self.assertNotIn("no_backup", classification)
|
||||||
@@ -151,61 +242,85 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
self.assertFalse(TEMPLATES.joinpath("prompts").exists())
|
self.assertFalse(TEMPLATES.joinpath("prompts").exists())
|
||||||
|
|
||||||
def test_agent_rules_template_defines_ticket_and_integration_contracts(self):
|
def test_agent_rules_template_defines_ticket_and_integration_contracts(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
self.assertIn("Matt Pocock 工程流程与 ticket 执行约束", rules)
|
normalized = " ".join(rules.split())
|
||||||
|
|
||||||
self.assertIn("{{PLAYBOOK_ROOT}}", rules)
|
self.assertIn("{{PLAYBOOK_ROOT}}", rules)
|
||||||
self.assertIn("Playbook 模板/供应商目录", rules)
|
self.assertIn("{{PLAYBOOK_SCRIPTS}}", rules)
|
||||||
self.assertIn("setup-matt-pocock-skills", rules)
|
for heading in (
|
||||||
self.assertIn("grill-with-docs", rules)
|
"## 任务入口",
|
||||||
self.assertIn("to-spec", rules)
|
"## 正式工程主链",
|
||||||
self.assertIn("to-tickets", rules)
|
"## On-ramps 与 detours",
|
||||||
self.assertIn("一次可以先生成多个 feature", rules)
|
"## Phase boundaries",
|
||||||
self.assertIn("`.scratch/queue.md`", rules)
|
"## 本地 Ticket 执行协议",
|
||||||
self.assertIn("多个 frontier tickets 可在 worktree 模式并发执行", rules)
|
"## 文档职责",
|
||||||
self.assertIn("不创建额外 worktree", rules)
|
"## 调度语义",
|
||||||
self.assertIn("共享同一文件系统", rules)
|
"## 执行隔离",
|
||||||
self.assertIn("跨机器或独立 clone", " ".join(rules.split()))
|
"## 主循环命令",
|
||||||
self.assertIn("只有 `reclaim` 可以接管", rules)
|
"## Git 与证据门禁",
|
||||||
self.assertIn("先提交实现", rules)
|
"## 辅助能力",
|
||||||
self.assertIn("Standards/Spec 双轴审查", rules)
|
"## Session 收尾",
|
||||||
self.assertIn("三个独立门禁,不能互相替代", rules)
|
):
|
||||||
|
self.assertIn(heading, rules, msg=f"missing section: {heading}")
|
||||||
|
|
||||||
|
for invariant in (
|
||||||
|
"`.scratch/queue.md`",
|
||||||
|
"多个 frontier tickets 可在 worktree 模式并发执行",
|
||||||
|
"只有 `reclaim` 可以接管",
|
||||||
|
"Standards/Spec 双轴审查",
|
||||||
|
"三个独立门禁,不能互相替代",
|
||||||
|
"跨机器或独立 clone",
|
||||||
|
"共享同一文件系统",
|
||||||
|
):
|
||||||
|
self.assertIn(invariant, normalized, msg=f"missing invariant: {invariant}")
|
||||||
|
|
||||||
for legacy in LEGACY_FLOW_TERMS:
|
for legacy in LEGACY_FLOW_TERMS:
|
||||||
self.assertNotIn(legacy, rules)
|
self.assertNotIn(legacy, rules)
|
||||||
|
|
||||||
def test_agent_rules_routes_each_matt_on_ramp_to_its_real_destination(self):
|
def test_agent_rules_states_whether_scratch_is_version_controlled(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
on_ramps = rules.split("## On-ramps 与 detours", 1)[1].split(
|
responsibilities = section(rules, "## 文档职责", "## 稳定知识维护")
|
||||||
"## Phase boundaries", 1
|
|
||||||
)[0]
|
self.assertIn("AGENT_RULES.local.md", responsibilities)
|
||||||
|
self.assertIn(
|
||||||
|
"纳入版本控制",
|
||||||
|
responsibilities,
|
||||||
|
msg="the review contract reads spec sources out of .scratch/, so "
|
||||||
|
"whether it is tracked has to be an explicit project decision",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_rules_routes_each_current_matt_on_ramp_to_its_destination(self):
|
||||||
|
rules = rules_text()
|
||||||
|
on_ramps = section(rules, "## On-ramps 与 detours", "## Phase boundaries")
|
||||||
|
|
||||||
self.assertIn("`wayfinder`", on_ramps)
|
self.assertIn("`wayfinder`", on_ramps)
|
||||||
self.assertIn("`to-spec -> to-tickets`", on_ramps)
|
self.assertIn("`to-spec -> to-tickets`", on_ramps)
|
||||||
self.assertIn("`research`", on_ramps)
|
self.assertIn("`research`", on_ramps)
|
||||||
self.assertIn("先进入 `grill-with-docs`", on_ramps)
|
self.assertIn("先进入 `grill-with-docs`", on_ramps)
|
||||||
self.assertIn("`handoff` 到独立目录运行", on_ramps)
|
self.assertNotIn("`prototype`", on_ramps)
|
||||||
self.assertIn("`prototype`", on_ramps)
|
|
||||||
self.assertIn("再 `handoff` 结论回原设计会话", on_ramps)
|
|
||||||
|
|
||||||
bug_route = rules.split("### 已明确预期行为的 bug", 1)[1].split(
|
bug_route = section(rules, "### 入口 3", "### 入口 4")
|
||||||
"## 正式工程主链", 1
|
|
||||||
)[0]
|
|
||||||
self.assertIn("`diagnosing-bugs` 完整执行 Phase 1-6", bug_route)
|
self.assertIn("`diagnosing-bugs` 完整执行 Phase 1-6", bug_route)
|
||||||
self.assertIn("不再进入 `implement` 或重复 `tdd`", bug_route)
|
|
||||||
self.assertNotIn("to-spec", bug_route)
|
self.assertNotIn("to-spec", bug_route)
|
||||||
|
self.assertIn(
|
||||||
|
"improve-codebase-architecture",
|
||||||
|
bug_route,
|
||||||
|
msg="diagnosing-bugs hands off to improve-codebase-architecture after "
|
||||||
|
"the fix lands, not to a design session before it",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"grill-with-docs",
|
||||||
|
bug_route,
|
||||||
|
msg="stopping a half-fixed defect to run a design session contradicts "
|
||||||
|
"the skill's own phase order",
|
||||||
|
)
|
||||||
|
|
||||||
def test_agent_rules_defines_a_local_ticket_execution_adapter(self):
|
def test_agent_rules_defines_a_local_ticket_execution_adapter(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
main_flow = rules.split("## 正式工程主链", 1)[1].split(
|
main_flow = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
||||||
"## On-ramps 与 detours", 1
|
adapter = section(rules, "## 本地 Ticket 执行协议", "## 文档职责")
|
||||||
)[0]
|
|
||||||
adapter = rules.split("## 本地 Ticket 执行协议", 1)[1].split(
|
|
||||||
"## 文档职责", 1
|
|
||||||
)[0]
|
|
||||||
normalized_adapter = " ".join(adapter.split())
|
|
||||||
|
|
||||||
self.assertIn("本地 ticket 执行协议", main_flow)
|
self.assertIn("本地 ticket 执行协议", main_flow)
|
||||||
self.assertNotIn("implement + tdd", main_flow)
|
|
||||||
self.assertIn("不直接调用上游 `implement`", normalized_adapter)
|
|
||||||
ordered_steps = (
|
ordered_steps = (
|
||||||
"读取已领取 ticket 的 spec",
|
"读取已领取 ticket 的 spec",
|
||||||
"按 `tdd`",
|
"按 `tdd`",
|
||||||
@@ -216,12 +331,46 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
positions = [adapter.index(step) for step in ordered_steps]
|
positions = [adapter.index(step) for step in ordered_steps]
|
||||||
self.assertEqual(positions, sorted(positions))
|
self.assertEqual(positions, sorted(positions))
|
||||||
|
|
||||||
|
def test_agent_rules_binds_state_and_evidence_to_claimed_git_context(self):
|
||||||
|
rules = rules_text()
|
||||||
|
claim = section(rules, "### 领取", "### 心跳和接管")
|
||||||
|
commands = " ".join(section(rules, "## 主循环命令", "## Git 与证据门禁").split())
|
||||||
|
|
||||||
|
# claim's contract is its output keys and what each one addresses.
|
||||||
|
for key in (
|
||||||
|
"FEATURE",
|
||||||
|
"TICKET",
|
||||||
|
"CONTROL_ROOT",
|
||||||
|
"STATE_ROOT",
|
||||||
|
"WORKSPACE",
|
||||||
|
"BRANCH",
|
||||||
|
"BASE",
|
||||||
|
"ISOLATION",
|
||||||
|
):
|
||||||
|
self.assertIn(f"`{key}`", claim, msg=f"claim output key undocumented: {key}")
|
||||||
|
self.assertNotIn(
|
||||||
|
"--state-root .scratch",
|
||||||
|
claim.split("stdout 返回", 1)[1],
|
||||||
|
msg="after a claim, state must be addressed by the absolute STATE_ROOT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evidence has to be bound to the claimed commits, not to free text.
|
||||||
|
for binding in (
|
||||||
|
"--review-base <同一feature HEAD>",
|
||||||
|
"--verified \"<ticket-verification.json>\"",
|
||||||
|
"--reviewed \"<ticket-review.json>\"",
|
||||||
|
"--verified \"<feature-verification.json>\"",
|
||||||
|
"--main-verified \"<main-candidate-verification.json>\"",
|
||||||
|
"--reviewed \"<feature-review.json>\"",
|
||||||
|
"把该 `FEATURE_HEAD` 合入 ticket branch",
|
||||||
|
):
|
||||||
|
self.assertIn(binding, commands, msg=f"missing evidence binding: {binding}")
|
||||||
|
|
||||||
def test_agent_rules_defines_mechanical_review_inputs_and_pass_mapping(self):
|
def test_agent_rules_defines_mechanical_review_inputs_and_pass_mapping(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
review_contract = rules.split("### Review 适配契约", 1)[1].split(
|
review = " ".join(
|
||||||
"### Ticket 完成或状态转换", 1
|
section(rules, "### Review 适配契约", "### Ticket 完成或状态转换").split()
|
||||||
)[0]
|
)
|
||||||
normalized_review_contract = " ".join(review_contract.split())
|
|
||||||
|
|
||||||
for required in (
|
for required in (
|
||||||
"fixed point",
|
"fixed point",
|
||||||
@@ -230,15 +379,19 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
"零个未解决的硬 finding",
|
"零个未解决的硬 finding",
|
||||||
"不得据此填写 `standards=pass` 或 `spec=pass`",
|
"不得据此填写 `standards=pass` 或 `spec=pass`",
|
||||||
):
|
):
|
||||||
self.assertIn(required, normalized_review_contract)
|
self.assertIn(required, review)
|
||||||
|
self.assertIn(
|
||||||
|
"不给 pass/fail 判定",
|
||||||
|
review,
|
||||||
|
msg="code-review emits findings only; the pass mapping is this "
|
||||||
|
"adapter's own layer and must not be presented as the skill's verdict",
|
||||||
|
)
|
||||||
|
|
||||||
def test_agent_rules_reads_claimed_context_after_claim_and_defines_lease_policy(self):
|
def test_agent_rules_reads_claimed_context_after_claim_and_defines_lease_policy(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
startup = rules.split("## 会话启动", 1)[1].split("## 任务入口", 1)[0]
|
startup = section(rules, "## 会话启动", "## 任务入口")
|
||||||
claim = rules.split("### 领取", 1)[1].split("### 心跳和接管", 1)[0]
|
claim = section(rules, "### 领取", "### 心跳和接管")
|
||||||
lease = rules.split("### 心跳和接管", 1)[1].split(
|
lease = section(rules, "### 心跳和接管", "### Review 适配契约")
|
||||||
"### Review 适配契约", 1
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
self.assertNotIn("当前 `.scratch/<feature>/spec.md`", startup)
|
self.assertNotIn("当前 `.scratch/<feature>/spec.md`", startup)
|
||||||
self.assertNotIn("当前 `.scratch/<feature>/issues/<ticket>.md`", startup)
|
self.assertNotIn("当前 `.scratch/<feature>/issues/<ticket>.md`", startup)
|
||||||
@@ -247,18 +400,75 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
self.assertIn("每 10 分钟", lease)
|
self.assertIn("每 10 分钟", lease)
|
||||||
self.assertIn("固定为 30 分钟", lease)
|
self.assertIn("固定为 30 分钟", lease)
|
||||||
|
|
||||||
def test_agent_rules_preserves_to_spec_seam_and_phase_boundary_contracts(self):
|
def test_agent_rules_orders_task_entries_by_cost_with_upgrade_conditions(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
main_flow = rules.split("## 正式工程主链", 1)[1].split(
|
entries = section(rules, "## 任务入口", "## 正式工程主链")
|
||||||
"## On-ramps 与 detours", 1
|
entry_headings = [h for h in headings(entries) if h.startswith("入口 ")]
|
||||||
)[0]
|
|
||||||
self.assertIn("不重新进行已经完成的需求采访", main_flow)
|
|
||||||
self.assertIn("完成 seam confirmation", main_flow)
|
|
||||||
self.assertIn("`tdd` 不得在未经确认的 seam 上开始", main_flow)
|
|
||||||
|
|
||||||
phase_boundaries = rules.split("## Phase boundaries", 1)[1].split(
|
self.assertEqual(
|
||||||
"## 文档职责", 1
|
entry_headings,
|
||||||
)[0]
|
[
|
||||||
|
"入口 1:直接执行",
|
||||||
|
"入口 2:单切片改动",
|
||||||
|
"入口 3:已明确预期行为的 bug",
|
||||||
|
"入口 4:新 feature 或设计变更",
|
||||||
|
],
|
||||||
|
msg="entries must stay ordered cheapest-first so the router can take "
|
||||||
|
"the first match",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
entries.count("**升级条件**"),
|
||||||
|
2,
|
||||||
|
msg="entry 1 and entry 2 each need an explicit upgrade trigger; "
|
||||||
|
"without one the router has no defined way out of a light path",
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_two = section(entries, "### 入口 2", "### 入口 3")
|
||||||
|
entry_four = section(entries, "### 入口 4", "### 非交互模式下的入口 4")
|
||||||
|
self.assertIn(
|
||||||
|
"不属于入口 3",
|
||||||
|
entry_two,
|
||||||
|
msg="known bugs must reach diagnosing-bugs before the generic slice path",
|
||||||
|
)
|
||||||
|
self.assertIn("入口 4", entry_two, msg="entry 2 must name its escalation target")
|
||||||
|
self.assertIn(
|
||||||
|
"边界不清时先按入口 2 起步",
|
||||||
|
entry_four,
|
||||||
|
msg="an uncertain boundary must start at the single-slice path, not "
|
||||||
|
"pre-pay the full chain",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_rules_gives_unattended_sessions_a_pre_ticket_fallback(self):
|
||||||
|
rules = rules_text()
|
||||||
|
fallback = section(
|
||||||
|
rules, "### 非交互模式下的入口 4", "## 正式工程主链"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("--result blocked", fallback)
|
||||||
|
self.assertIn(
|
||||||
|
"to-questionnaire",
|
||||||
|
fallback,
|
||||||
|
msg="grilling needs a user, so a ticketless unattended session must "
|
||||||
|
"have a defined way to hand questions back",
|
||||||
|
)
|
||||||
|
self.assertIn(".scratch/questions/<slug>.md", fallback)
|
||||||
|
self.assertIn("从 `grill-with-docs` 恢复", " ".join(fallback.split()))
|
||||||
|
|
||||||
|
def test_agent_rules_keeps_seam_confirmation_with_to_spec_and_tdd(self):
|
||||||
|
rules = rules_text()
|
||||||
|
main_flow = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
||||||
|
|
||||||
|
self.assertIn("`tdd` 不得在未经确认的 seam 上开始", main_flow)
|
||||||
|
self.assertIn(
|
||||||
|
"seam confirmation 的责任在 `to-spec` 与 `tdd`",
|
||||||
|
main_flow,
|
||||||
|
msg="the grilling skills never mention seams, so the rules must not "
|
||||||
|
"route seam confirmation through them",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_rules_orders_the_phase_boundary_options(self):
|
||||||
|
rules = rules_text()
|
||||||
|
phase_boundaries = section(rules, "## Phase boundaries", "## 本地 Ticket")
|
||||||
ordered_options = (
|
ordered_options = (
|
||||||
"继续当前 session",
|
"继续当前 session",
|
||||||
"使用 `clear`",
|
"使用 `clear`",
|
||||||
@@ -267,43 +477,120 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
"使用 `compact`",
|
"使用 `compact`",
|
||||||
)
|
)
|
||||||
positions = [phase_boundaries.index(option) for option in ordered_options]
|
positions = [phase_boundaries.index(option) for option in ordered_options]
|
||||||
|
|
||||||
self.assertEqual(positions, sorted(positions))
|
self.assertEqual(positions, sorted(positions))
|
||||||
self.assertIn("上下文过长不等于必须 `handoff`", phase_boundaries)
|
self.assertIn("150k", phase_boundaries)
|
||||||
|
self.assertIn("`handoff` 解决的是可移植性", phase_boundaries)
|
||||||
|
|
||||||
def test_agent_rules_binds_state_and_evidence_to_claimed_git_context(self):
|
def test_agent_rules_documents_every_main_loop_subcommand_and_required_flag(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = rules_text()
|
||||||
execution = rules.split("### 领取", 1)[1]
|
parsers = subcommand_parsers()
|
||||||
after_claim_output = execution.split("stdout 返回", 1)[1]
|
|
||||||
|
|
||||||
self.assertIn("绝对 `STATE_ROOT`", execution)
|
for command, parser in parsers.items():
|
||||||
self.assertIn("`CONTROL_ROOT` 是共享 Git control checkout", execution)
|
self.assertIn(
|
||||||
self.assertIn("`WORKSPACE` 是当前 ticket 的代码工作区", execution)
|
f"main_loop.py {command}",
|
||||||
self.assertNotIn("--state-root .scratch", after_claim_output)
|
rules,
|
||||||
self.assertIn("--review-base <同一feature HEAD>", execution)
|
msg=f"undocumented subcommand: {command}",
|
||||||
self.assertIn(
|
)
|
||||||
'commit=<HEAD>; base=<review-base>; standards=pass; spec=pass',
|
for flag in required_flags(parser):
|
||||||
execution,
|
self.assertIn(
|
||||||
|
flag, rules, msg=f"undocumented required flag: {command} {flag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
documented = set(re.findall(r"main_loop\.py ([a-z][a-z-]*)", rules))
|
||||||
|
self.assertEqual(
|
||||||
|
documented - set(parsers),
|
||||||
|
set(),
|
||||||
|
msg="the rules document subcommands the CLI does not expose",
|
||||||
)
|
)
|
||||||
self.assertIn("把该 `FEATURE_HEAD` 合入 ticket branch", execution)
|
|
||||||
self.assertIn(
|
def test_agent_rules_documents_executable_state_transition_commands(self):
|
||||||
"`--feature-head`、`--review-base` 和 review evidence 的 `base`",
|
rules = rules_text()
|
||||||
execution,
|
lease = section(rules, "### 心跳和接管", "### Review 适配契约")
|
||||||
|
transitions = section(
|
||||||
|
rules, "### Ticket 完成或状态转换", "### Feature 顺序集成"
|
||||||
)
|
)
|
||||||
self.assertIn(
|
|
||||||
"base=<最新main HEAD>; standards=pass; spec=pass",
|
self.assertNotIn("finish --result released|blocked", lease)
|
||||||
execution,
|
self.assertIn("--result blocked --reason", transitions)
|
||||||
|
self.assertIn("--result released", transitions)
|
||||||
|
self.assertIn("其他 `finish` 转换共用", transitions)
|
||||||
|
release_ticket = transitions.split("main_loop.py release-ticket", 1)[1]
|
||||||
|
self.assertNotIn("--repo-root", release_ticket)
|
||||||
|
self.assertNotIn("--owner", release_ticket)
|
||||||
|
|
||||||
|
def test_agent_rules_requires_snapshotted_evidence_artifacts(self):
|
||||||
|
rules = rules_text()
|
||||||
|
gate = " ".join(section(rules, "## Git 与证据门禁", "## 辅助能力").split())
|
||||||
|
|
||||||
|
for required in (
|
||||||
|
"UTF-8 JSON artifact",
|
||||||
|
"output_sha256",
|
||||||
|
"report_sha256",
|
||||||
|
".scratch/<feature>/evidence/",
|
||||||
|
"无法证明命令真的执行过",
|
||||||
|
):
|
||||||
|
self.assertIn(required, gate)
|
||||||
|
|
||||||
|
def test_agent_rules_only_references_installed_skills(self):
|
||||||
|
rules = rules_text()
|
||||||
|
skills = installed_skills()
|
||||||
|
machine_vocabulary = (
|
||||||
|
set(subcommand_parsers())
|
||||||
|
| set(MAIN_LOOP.ALLOWED_STATUSES)
|
||||||
|
| isolation_choices()
|
||||||
|
| NON_SKILL_BACKTICK_TOKENS
|
||||||
)
|
)
|
||||||
|
referenced = {
|
||||||
|
token
|
||||||
|
for token in re.findall(r"`([a-z][a-z0-9-]+)`", rules)
|
||||||
|
if token not in machine_vocabulary
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertTrue(referenced, msg="expected the rules to reference skills")
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(referenced - skills),
|
||||||
|
[],
|
||||||
|
msg="the rules reference skills that are not installed under skills/",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_rules_states_what_the_evidence_gate_cannot_check(self):
|
||||||
|
rules = rules_text()
|
||||||
|
gate = section(rules, "## Git 与证据门禁", "## 辅助能力")
|
||||||
|
|
||||||
|
self.assertIn("三个独立门禁,不能互相替代", gate)
|
||||||
|
self.assertIn(
|
||||||
|
"无法",
|
||||||
|
gate,
|
||||||
|
msg="the gate binds evidence to real commits but cannot prove a test "
|
||||||
|
"run happened; the rules must say so instead of implying enforcement",
|
||||||
|
)
|
||||||
|
self.assertIn("--main-verified", gate)
|
||||||
|
|
||||||
|
def test_agent_rules_documents_the_recovery_path_for_stuck_tickets(self):
|
||||||
|
rules = rules_text()
|
||||||
|
recovery = section(rules, "### 卡死与恢复", "## 执行隔离")
|
||||||
|
|
||||||
|
self.assertIn("release-ticket", recovery)
|
||||||
|
self.assertIn(
|
||||||
|
"`reclaim` 只接管 `claimed`",
|
||||||
|
recovery,
|
||||||
|
msg="reclaim cannot rescue a blocked ticket; the rules must point at "
|
||||||
|
"the command that can",
|
||||||
|
)
|
||||||
|
self.assertIn("BASE", recovery)
|
||||||
|
|
||||||
def test_agent_rules_limits_main_loop_to_shared_local_markdown_state(self):
|
def test_agent_rules_limits_main_loop_to_shared_local_markdown_state(self):
|
||||||
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
|
||||||
isolation = rules.split("## 执行隔离", 1)[1].split(
|
isolation = rules.split("## 执行隔离", 1)[1].split(
|
||||||
"## 主循环命令", 1
|
"## 主循环命令", 1
|
||||||
)[0]
|
)[0]
|
||||||
|
normalized = " ".join(isolation.split())
|
||||||
|
|
||||||
self.assertIn("`main_loop.py` 只支持 local Markdown tracker", isolation)
|
self.assertIn("`main_loop.py` 只支持 local Markdown tracker", normalized)
|
||||||
self.assertIn("没有远程 tracker adapter", isolation)
|
self.assertIn("没有 远程 tracker adapter", normalized)
|
||||||
self.assertIn("跨机器或独立\nclone 的并发不受支持", isolation)
|
self.assertIn("跨机器或独立 clone 的并发不受支持", normalized)
|
||||||
self.assertNotIn("必须改用具备远程", isolation)
|
self.assertNotIn("必须改用具备远程", normalized)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user