feat(workflow): enforce auditable agent rules

This commit is contained in:
csh
2026-08-12 12:40:31 +08:00
parent 1d9590f6a1
commit 91b6f8453e
7 changed files with 1273 additions and 318 deletions
+226 -44
View File
@@ -3,6 +3,7 @@ import argparse
from contextlib import contextmanager from 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
+53 -20
View File
@@ -508,6 +508,32 @@ def extract_block_lines(text: str, start: str, end: str) -> list[str]:
_AGENTS_BLOCK_START = "<!-- playbook:agents:start -->" _AGENTS_BLOCK_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,11 +761,34 @@ 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
) )
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") rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8", newline="\n")
log("Synced: AGENT_RULES.md") log("Synced: AGENT_RULES.md")
+252 -130
View File
@@ -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 来自已领取 ticketPhase 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 来自已领取 ticketPhase 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 treefrontier 清空并经用户确认后才进入 `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 pointticket 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 pointticket review 用 claim 的 `BASE` 或重试返回的 `FEATURE_HEAD`feature
review 用最新 `main` HEAD
- Spec sourcesticket 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` 并保留 workspaceblocked 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,39 +428,70 @@ 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` | 架构边界和长期维护性审查 |
| `codebase-design` | 需要 seam、deep module、依赖分类的词汇与判据 |
| `improve-codebase-architecture` | 修复落地后暴露出的结构问题 |
| `resolving-merge-conflicts` | feature 吸收 main 或集成时出现冲突 |
| `to-questionnaire` | 需要把待决问题整理成清单交回用户 |
| `commit-message` | 需要检查 staged diff 或生成提交信息 | | `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
View File
@@ -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
View File
@@ -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__":
+75
View File
@@ -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()
+376 -89
View File
@@ -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("`WORKSPACE` 是当前 ticket 的代码工作区", execution)
self.assertNotIn("--state-root .scratch", after_claim_output)
self.assertIn("--review-base <同一feature HEAD>", execution)
self.assertIn( self.assertIn(
'commit=<HEAD>; base=<review-base>; standards=pass; spec=pass', f"main_loop.py {command}",
execution, rules,
msg=f"undocumented subcommand: {command}",
) )
self.assertIn("把该 `FEATURE_HEAD` 合入 ticket branch", execution) for flag in required_flags(parser):
self.assertIn( self.assertIn(
"`--feature-head`、`--review-base` 和 review evidence 的 `base`", flag, rules, msg=f"undocumented required flag: {command} {flag}"
execution,
) )
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",
)
def test_agent_rules_documents_executable_state_transition_commands(self):
rules = rules_text()
lease = section(rules, "### 心跳和接管", "### Review 适配契约")
transitions = section(
rules, "### Ticket 完成或状态转换", "### Feature 顺序集成"
)
self.assertNotIn("finish --result released|blocked", lease)
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( self.assertIn(
"base=<最新main HEAD>; standards=pass; spec=pass", "无法",
execution, 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__":