♻️ refactor(cook-it-through): move workflow engine into skill

This commit is contained in:
csh
2026-08-20 17:02:34 +08:00
parent f6854d065a
commit 9b95bf2682
19 changed files with 3905 additions and 1474 deletions
+13 -1
View File
@@ -31,7 +31,7 @@ Playbook:工程规范与智能体规则合集,当前覆盖:
- `templates/memory-bank/`:稳定项目知识模板(project-brief、tech-context、system-patterns
- `templates/AGENTS.template.md`:入口导航模板(项目主入口)
- `templates/AGENT_RULES.template.md`Matt Pocock ticket-native 执行规则模板
- `templates/AGENT_RULES.template.md`常驻边界与 `cook-it-through` 工作流入口模板
### 快速部署
@@ -56,11 +56,15 @@ install_mode = "snapshot"
[sync_memory_bank]
project_name = "MyProject"
[install_skills]
mode = "all"
```
**部署行为**
- **配置节存在即启用**:只写需要同步的配置节
- **工作流依赖**:启用 `[sync_rules]` 时必须同时用 `[install_skills]` 安装 `cook-it-through`
- **AGENTS.md**:始终按区块更新(`<!-- playbook:xxx:start/end -->`
- **CLAUDE.md**:自动检测(根目录 → `.claude/`),不存在则创建;注入 `@AGENTS.md` / `@AGENT_RULES.md`
- **.gitignore**:启用 `[sync_rules]` 时更新 Playbook 标记区块,只忽略 `.scratch` 的锁、worktree 和临时文件
@@ -89,6 +93,7 @@ Layer 1: rulesets/ (≤50 行/语言,模板源)
Layer 2: skills/ (按需加载,$skill-name 触发)
├─ commit-message: 提交信息规范
├─ cook-it-through: 工程任务路由、本地 ticket 生命周期及其执行引擎
├─ tsl-syntax-reference: TSL 语法条目、写法验证和错误边界
├─ tsl-api-reference: TSL API 名称、签名、参数和返回值
└─ thirdparty/: 第三方同步 skills
@@ -194,6 +199,9 @@ skills = ["tsl-syntax-reference", "tsl-api-reference"]
[sync_memory_bank]
project_name = "MyProject"
[install_skills]
mode = "all"
```
```bash
@@ -229,6 +237,9 @@ skills = ["tsl-syntax-reference", "tsl-api-reference"]
[sync_memory_bank]
project_name = "MyProject"
[install_skills]
mode = "all"
```
3. 在目标项目根执行外部 clone 里的统一入口:
@@ -277,6 +288,7 @@ skills = ["tsl-syntax-reference", "tsl-api-reference"]
│ ├── docs/
│ ├── rulesets/
│ ├── scripts/
│ ├── skills/ # cook-it-through 内含主循环执行引擎
│ └── templates/
├── docs/project/ # 项目自有文档(架构、ADR、运行方式等)
├── playbook.toml # 统一入口配置
+15
View File
@@ -69,6 +69,21 @@ skills = [“commit-message”]
agents_home = ~/.claude # 或 ~/.agents
```
从本次安装集合中排除指定 skills;`mode = "all"``mode = "list"` 均可使用:
```toml
[install_skills]
mode = "all"
exclude = ["skill-a", "skill-b"]
agents_home = "~/.agents"
```
`exclude` 只跳过本次安装或更新,不会删除目标目录中已经存在的同名 skill。空数组
`exclude = []` 等同于不排除。
同一配置启用 `[sync_rules]` 时,`cook-it-through` 是规则入口的必需依赖:`mode = "list"`
必须显式列出,且两种 mode 都不得通过 `exclude` 排除。无效组合会在任何同步写入前失败。
`[install_skills]` 默认会先把已存在的 skill 目录重命名为 `*.bak.<timestamp>`,再复制新版本,便于手动回退。若不需要备份:
```toml
+2 -1
View File
@@ -49,8 +49,9 @@
[install_skills]
# 安装 skills 到本机平台目录
# mode = "list" # list: 指定 skills | all: 全部安装
mode = "all" # list: 指定 skills | all: 全部安装
# skills = ["commit-message"] # mode=list 时必填;依赖也需逐项列出
# exclude = ["skill-name"] # 可选:排除本次安装;sync_rules 要求保留 cook-it-through
# agents_home = "~/.agents" # Codex CLI 用 "~/.agents"Claude Code 用 "~/.claude"
# skill_link = "~/.claude" # 可选:在此目录下创建 skills/ 软链接指向 agents_home/skills/
# no_backup = false # 跳过备份,直接删除旧 skill 后重装
+58 -28
View File
@@ -15,6 +15,7 @@ ORDER = [
"install_skills",
"format_md",
]
RULES_WORKFLOW_SKILL = "cook-it-through"
SCRIPT_DIR = Path(__file__).resolve().parent
PLAYBOOK_ROOT = SCRIPT_DIR.parent
DOCS_INDEX_SECTION_HEADINGS = {
@@ -142,10 +143,6 @@ def resolve_docs_prefix(context: dict) -> str:
return join_playbook_subpath(resolve_playbook_root(context), "docs")
def resolve_playbook_scripts(context: dict) -> str:
return join_playbook_subpath(resolve_playbook_root(context), "scripts")
def read_git_commit(root: Path) -> str:
try:
result = subprocess.run(
@@ -404,7 +401,6 @@ def replace_placeholders(
text: str,
project_name: str | None,
date_value: str,
playbook_scripts: str | None,
playbook_root: str | None,
) -> str:
result = text.replace("{{DATE}}", date_value)
@@ -412,8 +408,6 @@ def replace_placeholders(
result = result.replace("{{PROJECT_NAME}}", project_name)
if playbook_root:
result = result.replace("{{PLAYBOOK_ROOT}}", playbook_root)
if playbook_scripts:
result = result.replace("{{PLAYBOOK_SCRIPTS}}", playbook_scripts)
return result
@@ -430,15 +424,12 @@ def replace_placeholders_in_file(
file_path: Path,
project_name: str | None,
date_value: str,
playbook_scripts: str | None,
playbook_root: str | None,
) -> None:
if file_path.suffix != ".md":
return
text = file_path.read_text(encoding="utf-8")
updated = replace_placeholders(
text, project_name, date_value, playbook_scripts, playbook_root
)
updated = replace_placeholders(text, project_name, date_value, playbook_root)
if updated != text:
file_path.write_text(updated, encoding="utf-8", newline="\n")
@@ -464,7 +455,6 @@ def sync_directory(
target_dir: Path,
project_name: str | None,
date_value: str,
playbook_scripts: str | None,
playbook_root: str | None,
force: bool,
no_backup: bool,
@@ -488,7 +478,6 @@ def sync_directory(
target_file,
project_name,
date_value,
playbook_scripts,
playbook_root,
)
written += 1
@@ -570,12 +559,11 @@ def update_agents_section(
end_marker: str,
project_name: str | None,
date_value: str,
playbook_scripts: str | None,
playbook_root: str | None,
) -> None:
template_text = template_path.read_text(encoding="utf-8")
template_text = replace_placeholders(
template_text, project_name, date_value, playbook_scripts, playbook_root
template_text, project_name, date_value, playbook_root
)
block = extract_block_lines(template_text, start_marker, end_marker)
if not block:
@@ -640,7 +628,6 @@ def sync_agents_template(context: dict) -> int:
return 0
project_name = resolve_project_name(context)
playbook_scripts = resolve_playbook_scripts(context)
playbook_root = resolve_playbook_root(context)
date_value = resolve_template_date(context)
@@ -651,7 +638,6 @@ def sync_agents_template(context: dict) -> int:
"<!-- playbook:framework:end -->",
project_name,
date_value,
playbook_scripts,
playbook_root,
)
sync_claude_md(project_root, context.get("config", {}))
@@ -767,15 +753,12 @@ def sync_rules_action(config: dict, context: dict) -> int:
force = bool(config.get("force", False))
project_name = resolve_project_name(context)
playbook_scripts = resolve_playbook_scripts(context)
playbook_root = resolve_playbook_root(context)
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
no_backup = bool(config.get("no_backup", False))
text = rules_src.read_text(encoding="utf-8")
text = replace_placeholders(
text, project_name, date_value, playbook_scripts, playbook_root
)
text = replace_placeholders(text, project_name, date_value, playbook_root)
if rules_dst.exists() and not force:
# The process itself is Playbook-owned, so keep it upgradable: refresh the
@@ -846,7 +829,6 @@ def sync_memory_bank_action(config: dict, context: dict) -> int:
return 2
project_name = config.get("project_name")
playbook_scripts = resolve_playbook_scripts(context)
playbook_root = resolve_playbook_root(context)
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
force = bool(config.get("force", False))
@@ -859,7 +841,6 @@ def sync_memory_bank_action(config: dict, context: dict) -> int:
memory_dst,
project_name,
date_value,
playbook_scripts,
playbook_root,
force,
no_backup,
@@ -1208,6 +1189,45 @@ def normalize_names(raw: object, label: str) -> list[str]:
return cleaned
def parse_skill_install_options(config: dict) -> tuple[str, set[str]]:
if not isinstance(config, dict):
raise ValueError("[install_skills] must be a table")
mode = str(config.get("mode", "list")).lower()
if mode not in ("all", "list"):
raise ValueError("mode must be list or all")
raw_exclude = config.get("exclude")
excluded = (
set(normalize_names(raw_exclude, "exclude"))
if raw_exclude not in (None, [])
else set()
)
return mode, excluded
def validate_rules_workflow_skill(config: dict) -> None:
if "sync_rules" not in config:
return
if "install_skills" not in config:
raise ValueError(
f"[sync_rules] requires [install_skills] to install "
f"{RULES_WORKFLOW_SKILL}"
)
install_config = config["install_skills"]
mode, excluded = parse_skill_install_options(install_config)
if RULES_WORKFLOW_SKILL in excluded:
raise ValueError(
f"[sync_rules] requires {RULES_WORKFLOW_SKILL}; "
"remove it from [install_skills].exclude"
)
if mode == "list":
selected = set(normalize_names(install_config.get("skills"), "skills"))
if RULES_WORKFLOW_SKILL not in selected:
raise ValueError(
f"[sync_rules] requires {RULES_WORKFLOW_SKILL} in "
"[install_skills].skills"
)
def normalize_globs(raw: object) -> list[str]:
if raw is None:
return ["**/*.md"]
@@ -1220,7 +1240,11 @@ def normalize_globs(raw: object) -> list[str]:
def install_skills_action(config: dict, context: dict) -> int:
mode = str(config.get("mode", "list")).lower()
try:
mode, excluded = parse_skill_install_options(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
agents_home = Path(config.get("agents_home", "~/.agents")).expanduser()
if not agents_home.is_absolute():
agents_home = (context["project_root"] / agents_home).resolve()
@@ -1261,9 +1285,14 @@ def install_skills_action(config: dict, context: dict) -> int:
else:
print(f"ERROR: skill not found: {name}", file=sys.stderr)
return 2
else:
print("ERROR: mode must be list or all", file=sys.stderr)
return 2
selected_exclusions = sorted(
{name for name, _src_root, _origin in skill_entries} & excluded
)
skill_entries = [
entry for entry in skill_entries if entry[0] not in excluded
]
for name in selected_exclusions:
log(f"Excluded: {name}")
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
no_backup = bool(config.get("no_backup", False))
@@ -1397,7 +1426,8 @@ def main(argv: list[str]) -> int:
try:
install_mode = resolve_install_mode(config)
except ValueError as exc:
validate_rules_workflow_skill(config)
except (TypeError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
+4 -3
View File
@@ -13,6 +13,7 @@
| ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `commit-message` | 根据 staged diff 生成符合仓库规范的提交信息,并判断是否应拆分提交 | 写 commit message、检查 staged 改动是否适合一个提交 |
| `gitea-fix-ci` | 基于 Gitea Actions run/job/log 诊断失败 CI,先形成修复计划再改代码;含 `fetch_ci_logs.py` 取证脚本 | Gitea PR checks 失败、远端 CI 红但本地需要定位 |
| `cook-it-through` | 运行任务路由、Matt 工程主链和本地 Markdown ticket 生命周期 | 处理实质性工程任务,或操作 `.scratch` queue、claim、finish、integrate |
| `tsl-syntax-reference` | 查询 TSL 语法条目,验证具体写法和错误边界;不负责 API、命名、风格、工具链或模块集成 | 写/改/审 TSL 时确认语言结构、表达式、控制流、对象模型和语法限制 |
| `tsl-api-reference` | 查询随 skill 分发的 TSL API 参考:按名精确查条目,或按中文关键词发现候选 | 写/审 TSL 时确认 builtin、dotnet、模块 API 的签名、参数、返回值和示例 |
@@ -32,9 +33,9 @@
| `code-review` | 对固定点后的提交做 Standards/Spec 双轴审查 |
| `handoff` | 跨 session 交接未完成上下文 |
安装时使用 `mode = "all"`,或在 `skills` 中逐项列出主链及其依赖;安装器不推导
skill 依赖。正式流程的调度、branch、worktree 和集成门禁由 `scripts/main_loop.py`
管理
安装时使用 `mode = "all"`,或在 `skills` 中逐项列出主链`cook-it-through` 及其依赖;
安装器不推导 skill 依赖。`cook-it-through` 同时定义工程和本地 ticket 协议,并由其内部
执行引擎强制执行状态机
### brooks-lint Suite
+73
View File
@@ -0,0 +1,73 @@
---
name: cook-it-through
description: "当用户要在一个目标项目里推进实质性工程任务、需要先确定执行深度和验证强度时使用;也用于该项目 .scratch 本地 ticket 队列的规划、领取、交付与集成。触发说法包括「按流程做这个」「这个需求怎么落地」「继续这个 ticket」「集成这个 feature」「.scratch 里还有什么可做」「谁在跑这个队列」。不用于纯 TSL 语法/API 查询、只写 commit message、诊断远端 Gitea CI。"
---
# Cook It Through
## 先确定项目与边界
- `<COOK_IT_THROUGH_ROOT>` 是本 skill 的绝对目录,只用于定位随 skill 安装的执行引擎;
它不是目标项目、状态目录或工作区。
- `<PROJECT_ROOT>` 只能来自用户或任务明确指定的 checkout,并由
`git rev-parse --show-toplevel` 验证。无法确定时保留占位符并暂停;不得把 skill source、
snapshot 或偶然 cwd 当项目根。
- 先读取目标项目的 `AGENT_RULES.md``AGENT_RULES.local.md``.agents/index.md`;不存在则跳过。
入口 1 只保留这些最小边界,不加载按需文件。
- 主循环只使用共享 local Markdown tracker、同一文件系统和 Git common directory
不得接入远程 tracker、独立 clone 或跨机器状态。
- 只从 `<COOK_IT_THROUGH_ROOT>/scripts/main_loop.py` 调用机器入口。构造任何命令前先运行
`python <COOK_IT_THROUGH_ROOT>/scripts/main_loop.py <command> --help`;帮助输出独占参数、
状态字段与 evidence schema 的权威。
## 任务路由
按成本从低到高选择第一个满足条件的入口;升级时把已有改动当作事实输入,不视为已批准方案。
路由前阅读 `AGENT_RULES.local.md` 声明的高爆炸半径路径:清单内文件即使改动看似机械,
也不得走入口 1。未声明时,触及构建、CI 或分发配置的改动最低入口 2。
### 入口 1:直接执行
只读分析、定位、审查和不改变可观察行为的机械修改;不生成 spec/tickets,不建 branch。
入口 1 不加载按需文件。
**升级条件**:出现可观察行为变化,或路径地板要求更高入口时转入口 2。
### 入口 2:单切片改动
单模块/既有接口、既有测试 seam、无新公开接口、配置、数据格式、依赖、迁移、兼容或并发,
且一个 session 可完成;不属于入口 3 时适用。详情读
[`workflows/single-session.md`](workflows/single-session.md)。
**升级条件**:需要新 seam、越过边界或出现未确认取舍时转入口 4。
### 入口 3:已明确预期行为的 bug
预期行为和失败反馈已明确;与入口 2 一起读
[`workflows/single-session.md`](workflows/single-session.md),由该文件决定诊断循环和升级。
### 入口 4:新 feature 或设计变更
产品/架构决策、新 seam/公开接口/配置/数据格式、跨模块、迁移/兼容、跨 session/并发,
或单 session 无法完成的工作。边界不清时先按入口 2 起步;确认后读
[`workflows/feature-planning.md`](workflows/feature-planning.md)。
enqueue 完成、恢复已入队工作或调用 status/claim 后读
[`workflows/ticket-execution.md`](workflows/ticket-execution.md)stdout 出现
`INTEGRATION_REQUIRED=<feature>@integrated` 后读
[`workflows/feature-integration.md`](workflows/feature-integration.md)。到阶段边界或上下文有压力时读
[`rules/session-boundary.md`](rules/session-boundary.md)。
## 常驻红线
- 禁止手工修改 ticket `Status``main-loop:ticket-state`、queue block 或 feature state。
- 禁止伪造或复用证据 artifactintegration dependency 不可见时禁止继续。
- 禁止 stash、reset 或覆盖其他 session 改动;禁止绕过 integration frontier 或运行
`git add .scratch`
- 只允许 local Markdown tracker;禁止把远程 tracker、独立 clone 或跨机器状态接入主循环。
## 恢复与完成
`clear``compact``handoff`、上下文压缩或新 session 恢复时,必须重新加载本文件与当前路由文件;
不得只依据 `status` 输出继续。保持活动 claim 的 heartbeat;结束前做 fresh verification
并报告完成、未完成、证据与风险。
@@ -0,0 +1,29 @@
# 阶段边界与稳定知识
到达阶段边界或感到上下文压力时,只按下列顺序选择首个满足项:
1. **继续当前 session**:下一阶段需要当前 session 作为 primary source,或剩余 smart zone 足够
(约 150k tokens)。
2. **使用 `clear`**:上下文与下一阶段无关,且没有必须随身携带的未交接事实。
3. **使用 `handoff`**:跨 harness、目录、仓库或同事,或需要 mid-phase 旁支;
`handoff` 解决的是可移植性,不是上下文过长。
4. **交给 subagent**:工作可独立、边界清楚并可 AFK,且主 session 不需要实时决策。
5. **使用 `compact`**:其余情况;说明要保留的事实、当前路由和下一动作。
`clear``compact``handoff`、压缩或恢复后,重新加载 `SKILL.md` 与当前路由文件,
并重新读取会改变下一步动作的项目规则;不得只依据 `status` 输出继续。
稳定知识不能替代阶段上下文。
## 稳定知识准入与归属
只把已经验证且可复现的事实写入稳定知识,并且至少满足一项:重新发现成本高、
不能从代码直接看出、下一 session 仍需要。
- `memory-bank/project-brief.md`:稳定定位、边界和目标。
- `memory-bank/tech-context.md`:已验证的工具链和验证入口。
- `memory-bank/system-patterns.md`:当前模块边界与不变量。
- `CONTEXT.md`:稳定领域词汇;关键取舍及理由写入 `docs/adr/`;项目执行规则写
`AGENT_RULES.local.md`
- `handoff` 产物写入 OS 临时目录,不写稳定知识文件。
按需使用 `domain-modeling``codebase-design` 只作词汇来源,不单独起会话。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
"""Pure identities and dependency scheduling for the ticket main loop.
The module deliberately has no filesystem, Git, clock, or locking dependency.
Callers provide an immutable snapshot and receive deterministic domain results.
"""
from dataclasses import dataclass
import re
from typing import TypeAlias
FEATURE_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
TICKET_NUMBER_RE = re.compile(r"^\d{2,}$")
TICKET_ID_RE = re.compile(
r"^(?P<feature>[a-z0-9][a-z0-9-]*)/(?P<number>\d{2,})$"
)
FEATURE_INTEGRATION_ID_RE = re.compile(
r"^(?P<feature>[a-z0-9][a-z0-9-]*)@integrated$"
)
SATISFIED_TICKET_STATUSES = frozenset({"resolved", "skipped"})
VALID_TICKET_STATUSES = frozenset(
{
"ready-for-agent",
"claimed",
"blocked",
"resolved",
"skipped",
}
)
class SchedulerError(ValueError):
"""The queued dependency graph violates the scheduler contract."""
@dataclass(frozen=True, order=True)
class FeatureId:
value: str
def __post_init__(self) -> None:
if not isinstance(self.value, str) or not FEATURE_SLUG_RE.fullmatch(self.value):
raise SchedulerError(f"invalid feature identity: {self.value}")
@classmethod
def parse(cls, raw: str) -> "FeatureId":
if not isinstance(raw, str):
raise SchedulerError(f"invalid feature identity: {raw}")
return cls(raw)
def __str__(self) -> str:
return self.value
@dataclass(frozen=True)
class TicketId:
feature: FeatureId
number: str
def __post_init__(self) -> None:
if not isinstance(self.feature, FeatureId) or not isinstance(
self.number, str
) or not TICKET_NUMBER_RE.fullmatch(self.number):
raise SchedulerError(
f"invalid ticket identity: {self.feature}/{self.number}"
)
@classmethod
def parse(cls, raw: str) -> "TicketId":
if not isinstance(raw, str):
raise SchedulerError(
f"invalid ticket identity '{raw}'; expected <feature>/<number>"
)
match = TICKET_ID_RE.fullmatch(raw)
if not match:
raise SchedulerError(
f"invalid ticket identity '{raw}'; expected <feature>/<number>"
)
return cls(
FeatureId.parse(match.group("feature")),
match.group("number"),
)
def __str__(self) -> str:
return f"{self.feature}/{self.number}"
@dataclass(frozen=True)
class FeatureIntegrationId:
feature: FeatureId
@classmethod
def parse(cls, raw: str) -> "FeatureIntegrationId":
if not isinstance(raw, str):
raise SchedulerError(
"invalid feature integration identity "
f"'{raw}'; expected <feature>@integrated"
)
match = FEATURE_INTEGRATION_ID_RE.fullmatch(raw)
if not match:
raise SchedulerError(
"invalid feature integration identity "
f"'{raw}'; expected <feature>@integrated"
)
return cls(FeatureId.parse(match.group("feature")))
def __str__(self) -> str:
return f"{self.feature}@integrated"
Dependency: TypeAlias = TicketId | FeatureIntegrationId
@dataclass(frozen=True)
class TicketRecord:
id: TicketId
slug: str
status: str
dependencies: tuple[Dependency, ...]
@dataclass(frozen=True)
class FeatureRecord:
id: FeatureId
tickets: tuple[TicketRecord, ...]
integrated: bool = False
integration_blocked: bool = False
class Scheduler:
"""Validate and query one immutable, queue-ordered global graph snapshot."""
def __init__(self, features: tuple[FeatureRecord, ...]) -> None:
self._features = features
feature_ids = [feature.id for feature in features]
for feature_id in feature_ids:
if not isinstance(feature_id, FeatureId):
raise SchedulerError(f"invalid feature identity: {feature_id}")
if len(feature_ids) != len(set(feature_ids)):
duplicate = next(
feature_id
for feature_id in feature_ids
if feature_ids.count(feature_id) > 1
)
raise SchedulerError(f"duplicate queued feature: {duplicate}")
self._feature_by_id = {feature.id: feature for feature in features}
self._ticket_by_id: dict[TicketId, TicketRecord] = {}
for feature in features:
for ticket in feature.tickets:
if not isinstance(ticket.id, TicketId):
raise SchedulerError(f"invalid ticket identity: {ticket.id}")
if ticket.id.feature != feature.id:
raise SchedulerError(
f"ticket {ticket.id} is stored under feature {feature.id}"
)
if ticket.status not in VALID_TICKET_STATUSES:
raise SchedulerError(
f"{ticket.id}: invalid status {ticket.status}"
)
if ticket.id in self._ticket_by_id:
raise SchedulerError(f"duplicate ticket identity: {ticket.id}")
self._ticket_by_id[ticket.id] = ticket
self._validate_integration_state()
self._queue_index = {
feature.id: index for index, feature in enumerate(features)
}
self._validate_dependency_targets()
self._edges = self._build_edges()
self._validate_acyclic()
def _validate_integration_state(self) -> None:
first_pending: FeatureIntegrationId | None = None
for feature in self._features:
integration_id = FeatureIntegrationId(feature.id)
if feature.integrated:
if first_pending is not None:
raise SchedulerError(
f"{integration_id}: earlier integration is pending: "
f"{first_pending}"
)
for ticket in sorted(feature.tickets, key=lambda item: str(item.id)):
if ticket.status not in SATISFIED_TICKET_STATUSES:
raise SchedulerError(
f"{integration_id}: unsatisfied ticket {ticket.id} "
f"has status {ticket.status}"
)
elif first_pending is None:
first_pending = integration_id
def _validate_dependency_targets(self) -> None:
for ticket_id in sorted(self._ticket_by_id, key=str):
ticket = self._ticket_by_id[ticket_id]
for dependency in ticket.dependencies:
if not isinstance(dependency, (TicketId, FeatureIntegrationId)):
raise SchedulerError(
f"{ticket.id}: invalid dependency {dependency}"
)
if len(ticket.dependencies) != len(set(ticket.dependencies)):
duplicate = next(
dependency
for dependency in ticket.dependencies
if ticket.dependencies.count(dependency) > 1
)
raise SchedulerError(
f"{ticket.id}: duplicate dependency {duplicate}"
)
for dependency in sorted(ticket.dependencies, key=str):
if dependency == ticket.id:
raise SchedulerError(
f"{ticket.id}: self dependency {dependency}"
)
if dependency.feature not in self._feature_by_id:
raise SchedulerError(
f"{ticket.id}: dependency feature not queued: "
f"{dependency.feature}"
)
if (
isinstance(dependency, TicketId)
and dependency not in self._ticket_by_id
):
raise SchedulerError(
f"{ticket.id}: dependency ticket not found: {dependency}"
)
def _build_edges(self) -> dict[Dependency, tuple[Dependency, ...]]:
edges: dict[Dependency, tuple[Dependency, ...]] = {
ticket_id: tuple(sorted(ticket.dependencies, key=str))
for ticket_id, ticket in self._ticket_by_id.items()
}
previous: FeatureIntegrationId | None = None
for feature in self._features:
integration_id = FeatureIntegrationId(feature.id)
dependencies: list[Dependency] = [
ticket.id for ticket in feature.tickets
]
if previous is not None:
dependencies.append(previous)
edges[integration_id] = tuple(sorted(dependencies, key=str))
previous = integration_id
return edges
def _validate_acyclic(self) -> None:
visited: set[Dependency] = set()
active: set[Dependency] = set()
stack: list[Dependency] = []
def visit(node: Dependency) -> None:
if node in visited:
return
if node in active:
start = stack.index(node)
cycle = (*stack[start:], node)
raise SchedulerError(
"dependency cycle: " + " -> ".join(map(str, cycle))
)
active.add(node)
stack.append(node)
for dependency in self._edges[node]:
visit(dependency)
stack.pop()
active.remove(node)
visited.add(node)
for node in sorted(self._edges, key=str):
visit(node)
def _dependency_is_satisfied(self, dependency: Dependency) -> bool:
if isinstance(dependency, TicketId):
return (
self._ticket_by_id[dependency].status
in SATISFIED_TICKET_STATUSES
)
return self._feature_by_id[dependency.feature].integrated
def unsatisfied_dependencies(
self, node: Dependency
) -> tuple[Dependency, ...]:
return tuple(
dependency
for dependency in self._edges[node]
if not self._dependency_is_satisfied(dependency)
)
def integration_dependencies(
self,
ticket_id: TicketId,
) -> tuple[FeatureIntegrationId, ...]:
return tuple(
dependency
for dependency in self._edges[ticket_id]
if isinstance(dependency, FeatureIntegrationId)
)
@property
def integration_frontier(self) -> FeatureIntegrationId | None:
for feature in self._features:
if not feature.integrated:
return FeatureIntegrationId(feature.id)
return None
@property
def integration_frontier_ready(self) -> bool:
frontier = self.integration_frontier
if frontier is None:
return False
feature = self._feature_by_id[frontier.feature]
return (
not feature.integration_blocked
and not self.unsatisfied_dependencies(frontier)
)
def feature_state(self, feature_id: FeatureId) -> str:
feature = self._feature_by_id[feature_id]
if feature.integrated:
return "integrated"
if feature.integration_blocked:
return "blocked"
statuses = {ticket.status for ticket in feature.tickets}
if statuses <= SATISFIED_TICKET_STATUSES:
return "ready-to-integrate"
if "claimed" in statuses or statuses & SATISFIED_TICKET_STATUSES:
return "active"
if any(ticket_id.feature == feature_id for ticket_id in self.ticket_frontier):
return "queued"
return "blocked"
@property
def ticket_frontier(self) -> tuple[TicketId, ...]:
claimable = (
ticket
for feature in self._features
if not feature.integrated
for ticket in feature.tickets
if ticket.status == "ready-for-agent"
and not self.unsatisfied_dependencies(ticket.id)
)
return tuple(
ticket.id
for ticket in sorted(
claimable,
key=lambda ticket: (
self._queue_index[ticket.id.feature],
int(ticket.id.number),
ticket.id.number,
ticket.slug,
),
)
)
def parse_dependencies(raw: str, owner: TicketId) -> tuple[Dependency, ...]:
"""Parse the one canonical ``Blocked by`` representation."""
if not isinstance(raw, str):
raise SchedulerError(
f"{owner}: invalid dependency value; expected a Markdown string"
)
if not isinstance(owner, TicketId):
raise SchedulerError(f"invalid dependency owner: {owner}")
value = raw.strip()
if value == "None":
return ()
entries = value.split(";")
dependencies: list[Dependency] = []
for raw_entry in entries:
entry = raw_entry.strip()
ticket_match = TICKET_ID_RE.fullmatch(entry)
integration_match = FEATURE_INTEGRATION_ID_RE.fullmatch(entry)
if ticket_match:
dependency: Dependency = TicketId(
FeatureId.parse(ticket_match.group("feature")),
ticket_match.group("number"),
)
elif integration_match:
dependency = FeatureIntegrationId(
FeatureId.parse(integration_match.group("feature"))
)
else:
raise SchedulerError(
f"{owner}: invalid dependency '{entry}'; expected "
"<feature>/<number> or <feature>@integrated separated by ';'"
)
if dependency in dependencies:
raise SchedulerError(f"{owner}: duplicate dependency {dependency}")
if dependency == owner:
raise SchedulerError(f"{owner}: self dependency {dependency}")
dependencies.append(dependency)
return tuple(dependencies)
@@ -0,0 +1,26 @@
# Feature 集成
stdout 出现 `INTEGRATION_REQUIRED=<feature>@integrated` 后,先读 `main_loop.py integrate --help`
只在全局锁下处理严格 integration frontier;后序 feature 即使完成也不得越序。
feature 必须先显式吸收最新 main,再完成 feature verification、main candidate verification 和
最终双轴 review。收到 `RETRY: feature needs main sync` 时同步后全部重跑;不得自行 merge 或
绕过 frontier。
merge 冲突令该 feature integration blocked,但不阻止后续 ticket 开发;必要时用
`main_loop.py block-feature` 记录边界,解决后用 `main_loop.py release-feature`
## 三道证据门禁
三个独立门禁,不能互相替代:feature、main candidate 和 review 都必须是 fresh UTF-8 JSON
artifact,精确字段以 `integrate --help` 为准。调用 `main_loop.py integrate` 时只提交 frontier
feature、已验证的 `FEATURE_HEAD`、feature verification、main verification 和 review
`skipped` ticket 还必须显式授权 partial integration。收到 `NOOP`/`RETRY` 仍按 stdout 处理。
## 持久化最终状态
集成成功后,先完成 merge commit,再只暂存 `.scratch/<feature>/` 及确被改写的
`.scratch/queue.md`,提交 final workflow state;不要运行 `git add .scratch`,不得 amend/squash。
保持 `.main-loop.json``integration_commit` 指向 `MAIN_INTEGRATION_COMMIT`,解析并报告全部
`WARNING` 与残留 worktree。已集成 feature 的重试必须幂等。
@@ -0,0 +1,55 @@
# 入口 4feature 规划与入队
## 选择与建立上下文
巨大而模糊的工作先用 `wayfinder`;需要外部事实时用 `research`。先进入 `grill-with-docs`
研究报告不能替代 grilling。进入 `grill-with-docs` 前,重读
`memory-bank/project-brief.md``memory-bank/tech-context.md`
`memory-bank/system-patterns.md` 以及会改变设计的项目规则、领域文档和 ADR。
首次运行 `setup-matt-pocock-skills` 时选择 local Markdown tracker。
`grilling` 清空 design frontier 并取得用户确认;seam confirmation 在 `to-spec``tdd`
`tdd` 不得在未经确认的 seam 上开始。按顺序推进:
`setup-matt-pocock-skills -> grill-with-docs -> to-spec -> to-tickets`
`-> main_loop.py enqueue -> 提交 planning baseline -> main_loop.py claim`
关键规划链是 `to-spec -> to-tickets`
## Ticket 身份与依赖格式
- `FeatureId`:小写字母或数字开头,只含小写字母、数字和连字符。
- `TicketId`qualified `feature-slug/NN`feature slug 与至少两位数字共同构成身份。
- `FeatureIntegrationId``feature-slug@integrated`
- Dependency 只能是后两类身份;标题和文件名中的可读 slug 不是身份。
每个 ticket 必须恰有一行 `**Blocked by:** None`,或
`**Blocked by:** feature-a/01; feature-b@integrated`。执行 hard cut`None` 只能单独出现;
同 feature 也写完整身份;只用分号;拒绝裸数字、标题描述、逗号、隐式当前 feature、
非法/重复/缺失目标、自依赖和跨 feature cycle。旧格式必须在 enqueue 前人工迁移;
禁止增加 fallback、双解析器或自动重写。
## 全局图与两个 Frontier
- 锁内加载全部 queued features:每个 ticket 是节点,每个 feature 增加 integration node。
- ticket 使用 `Blocked by` 边;integration node 依赖本 feature 全部 ticketsqueue 顺序只连接
integration nodes,不形成 ticket claim 门槛。
- ticket frontier 是依赖已满足且 `ready-for-agent` 的 tickets,按 queue feature、ticket number、
稳定 slug 排序;integration frontier 是首个尚未集成的 feature,两者独立。
- ticket 在 `resolved``skipped` 时满足;integration dependency 只在持久状态含有效
`integration_commit` 时满足。`skipped` 令 feature partial,集成时必须显式授权。
- 枚举顺序不得改变 frontier、claim 或错误顺序。
## 入队与 planning baseline
第三方 `to-tickets` 只定义通用 tracker 行为;主循环格式以本文件和执行引擎为准。
跨 feature 前向依赖必须把相关 feature 放在同一批次;同批重复 `--feature` 与现有 queue 一起校验。
任一解析、目标或 DAG 校验失败,都不得写 queue、ticket metadata/status 或 feature state
`enqueue` 是最终机器校验边界。参数与结果只查相应 `--help`,不维护命令职责表。
生成和入队不隐式提交。任何 claim 前,提交 `.scratch/<feature>/spec.md`
`.scratch/<feature>/issues/*.md``.scratch/queue.md` 作为 planning baseline;随后读
`workflows/ticket-execution.md`,由 `main_loop.py status` / `main_loop.py claim` 取得正式 assignment。
尚未 claim ticket 时,加载 `to-questionnaire`,把问题写入 control checkout 的
`.scratch/questions/<slug>.md`,并从 `grill-with-docs` 恢复;不得把问题写入稳定知识。
@@ -0,0 +1,27 @@
# 入口 2/3:单 session 工作
## 入口 2:单切片改动
修改前记录当前 `HEAD` 为 review fixed point。确认工作仍局限于单模块或既有接口、
既有测试 seam,且没有新公开接口、配置、数据格式、依赖、迁移、兼容或并发边界。
`tdd` 实现并验证,提交全部实现;再以 `<fixed-point>` 作为 `code-review` 的 fixed point
仅运行 Standards axis。修复硬 finding 后重新提交、验证和 review。
这里不生成 spec/tickets,也不提前创建 ticket branch。
## 入口 3:已明确预期行为的 bug
`diagnosing-bugs` 完整执行 Phase 1-6;它的 Phase 5 已包含 test-first
不重复 `tdd` 或 grilling。先建立可重复的失败反馈;若环境、artifact 或许可不足,
记录缺口并请求补充。
缺少 seam 时可记录后完成修复,落地后再用 `improve-codebase-architecture`
不要因为 bug 已经明确而跳过根因验证,也不要把未证实的猜测当修复依据。
## 完成与升级
完成后运行 fresh verification,并保留 fixed point、验证命令和结果。
若出现新 seam、兼容/架构取舍、跨模块或跨 session 影响,先完成当前入口可验证的收尾,
再转 `workflows/feature-planning.md`;不要把已有修改当作已批准设计。
入口 3 的 bug 段不进入 `grill-with-docs`;只有暴露新的产品或架构取舍时才升级入口 4。
@@ -0,0 +1,78 @@
# 入队后的 ticket 执行
## 建立机器上下文
先读 `main_loop.py status --help`,再按 stdout 分开读取 qualified `TICKET_FRONTIER`
`INTEGRATION_FRONTIER`,并处理 `CLAIM``STALE``BLOCKED``TICKET_ERROR`
`BLOCKED_DEPENDENCY``WAITING_ON``FEATURE_BLOCKED``MAIN_INTEGRATION_COMMIT`
单 feature 空 frontier 不是全局 BUSY。
claim 前使用 `--state-root "<PROJECT_ROOT>/.scratch"`claim 必须显式传绝对
`--repo-root "<PROJECT_ROOT>"`、全局唯一 `--owner``--isolation`。只从
`main_loop.py claim --help` 取得参数。成功 assignment 的键是 `FEATURE``TICKET`
`CONTROL_ROOT``STATE_ROOT``WORKSPACE``BRANCH``BASE``ISOLATION`
领取后立即从返回的绝对 `STATE_ROOT` 读取 feature spec/ticket。领取后实现前重读三个
memory-bank 文件;不要预猜 ticket,也不要使用 worktree 中的 `.scratch` 副本。
`NO FEATURES``NOOP``BUSY``RETRY``INTEGRATION_REQUIRED=<feature>@integrated`
是 stdout/0 的机器结果,`ERROR` 是 stderr/2;始终解析 stdout。只有全局无可领取 ticket 且
integration frontier 可推进时,才把 `INTEGRATION_REQUIRED` 交给 feature integration 路由。
## 🔴 Integration visibility RETRY
stdout 出现 `RETRY: dependency integration is not visible` 时,立即停止 claim 和实现。
必须原样取得 `TICKET``DEPENDENCY``INTEGRATION_COMMIT``WORKSPACE``BRANCH`
`BRANCH_HEAD``TICKET_BRANCH``TICKET_BRANCH_HEAD``SYNC_BRANCH``MAIN_BRANCH`
`MAIN_HEAD``SYNC_COMMAND`
任一字段缺失就报告原始 stdout 并停止,不自行推导 fetch、merge、rebase 或 cherry-pick。
从任一现有目录执行返回的 `SYNC_COMMAND`;它负责创建或使用 `WORKSPACE`,不要预先切换到
可能不存在的目录。成功后重新运行 status/claim;取得正式 assignment 前不得继续。
## 隔离、租约与恢复
- 请求 in-place 时,任意活动 claim 都令其 BUSY;活动 in-place claim 也阻止新 claim 和
pending integration。
- worktree ticket 可跨 feature 并发;活动 worktree 不阻止更早 feature integration。
- `auto` 在全局无 claim 时选 in-place,否则选 worktree;同 owner 只恢复自己的 ticket。
- in-place 要求 control checkout 除 `.scratch` 外干净;不要 stash、reset 或覆盖其他 session 改动。
claim 租约固定为 30 分钟,至少每 10 分钟运行 `main_loop.py heartbeat`,并在长验证/review
前后续租。`reclaim` 只接管 stale 的 `claimed` ticket,保留 branch、workspace、未提交改动和
`BASE`;接管后原 owner 不得 heartbeat/finish。
blocked ticket 用原 owner 的 `main_loop.py finish --result released` 释放;原 session 丢失时才用
`main_loop.py release-ticket`,不要用 reclaim。claim 环境准备失败会占住该 ticket,也按此恢复;
blocked/skipped 必须给 reasonreleased 回到 ready 并保留 workspace。禁止猜 ticket、复用通用
owner 或自动转移 stale claim。
## 实现、Review 与 Finish
按顺序执行:读取已领取 ticket 的 spec → 按 `tdd` 实现与验证 → 提交全部实现 →
运行 `code-review` 的 Standards/Spec 双轴 → 修复硬 finding 后重新提交、验证、完整 review →
结构化证据调用 main_loop.py finish。
ticket review 的 fixed point 是 claim 返回的 `BASE`,或 RETRY 返回的 `FEATURE_HEAD`
Spec sources 是 `<STATE_ROOT>/<feature>/spec.md`
`<STATE_ROOT>/<feature>/issues/<NN>-*.md`。只有对应 axis 零个未解决的硬 finding 才能映射为 pass
`code-review` 不给 pass/fail 判定,来源缺失、axis 跳过或仍有 finding 时不得填写 pass。
收到 `RETRY: feature advanced` 时,把该 `FEATURE_HEAD` 合入 ticket branch,重新验证并以它为新
review base;只有成功合入 feature branch 后 ticket 才能 resolved。调用前读取
`main_loop.py finish --help`,不得复用旧 artifact。
无人值守或当前 session 无法继续时,按 `main_loop.py finish --result blocked` 记录具体 reason。
按各自 `--help` 调用 `main_loop.py status``main_loop.py heartbeat``main_loop.py finish`
`main_loop.py reclaim``main_loop.py release-ticket``main_loop.py block-feature`
`main_loop.py release-feature`;不维护命令职责表。
## 证据门禁
ticket、feature、main candidate 验证是三个独立门禁,不能互相替代。只接受 fresh UTF-8 JSON artifact
精确 verification/review schema 以 `finish --help``integrate --help` 为准。
主循环校验摘要、Git object、branch tip、review base 与 workspace,并快照到
`.scratch/<feature>/evidence/`
artifact 无法证明命令真的执行过或报告来自真实 review;无法证明命令真的执行过时不得伪造 pass。
完成前做 fresh verification,并按帮助输出构造结构化参数。禁止手工修改 ticket `Status`
禁止为旧 Blocked by 格式增加 fallback,禁止伪造或复用证据 artifact。
+16 -505
View File
@@ -2,524 +2,35 @@
<!-- playbook:rules:start -->
目的:为本仓库提供稳定的 Matt Pocock 工程流程与 ticket 执行约束
`.scratch/` 是 spec、ticket、feature 队列和执行状态的唯一事实源。
`memory-bank/` 保存稳定的项目定位、技术上下文和当前系统模式。
`CONTEXT.md` 保存稳定领域词汇,`docs/adr/` 保存关键架构决策。
目的:为本仓库提供稳定的指令优先级、项目边界和 Playbook 工作流入口
## 优先级
1. 系统/开发者指令
2. 项目私有规则:`AGENT_RULES.local.md`(如存在)
3. 仓库规则:`.agents/` `AGENTS.md`
4. 本文件
3. 仓库规则:`.agents/index.md` 路由的语言规则`AGENTS.md`
4. 本文件;已加载 skill 的指令是本文件的按需扩展,服从同一优先级
## 沟通原则
## 沟通
- 统一使用简体中文
- 发现用户理解有误时礼貌纠正
- 不给时间估算,专注事实、风险下一步
- 使用简体中文
- 发现理解有误时礼貌纠正
- 不给时间估算,只说明事实、风险下一步
## 项目边界
- `{{PLAYBOOK_ROOT}}/` 是 Playbook 模板/供应商目录,不是业务项目源码
- 除非任务明确维护 Playbook,不得修改 `{{PLAYBOOK_ROOT}}/` 下内容
- 当前项目的生效规则位于项目根目录的 `AGENT_RULES.md`
`AGENT_RULES.local.md``AGENTS.md``.agents/`
- 除非任务明确维护 Playbook,不得修改 `{{PLAYBOOK_ROOT}}/`
- 根规则位于 `AGENT_RULES.md``AGENT_RULES.local.md``AGENTS.md`;语言规则由
`.agents/index.md`(如存在)按任务路由
- 搜索、批量修改、review 和提交时默认排除 `{{PLAYBOOK_ROOT}}/`
- 不覆盖、stash、reset 或提交不属于当前 ticket 的既有改动
- 不覆盖、stash、reset 或提交不属于当前任务的既有改动
## 会话启动
## 工作流入口
处理首个实质性任务前,按相关性读取;不存在则跳过:
1. `AGENT_RULES.local.md`
2. `.agents/index.md`
3. `memory-bank/project-brief.md`
4. `memory-bank/tech-context.md`
5. `memory-bank/system-patterns.md`
6. `docs/agents/issue-tracker.md`
7. `docs/agents/domain.md`
8. `CONTEXT.md``CONTEXT-MAP.md`
9. 相关 `docs/adr/`
10. `main_loop.py status --state-root .scratch` 的当前状态
## 任务入口
四个入口按成本递增排列,**从上往下取第一个满足的**。每个入口都定义了强制升级条件;条件成立
时立即停止当前路径并转入指定入口,已产生的改动只作为事实输入,不视为已批准方案。
### 入口 1:直接执行
只读分析、定位、审查,以及不改变可观察行为的机械修改(重命名、格式化、注释、导入整理、纯
文档措辞)。不生成 spec/tickets,不建 branch。仍须遵守项目规则、验证实际结果,并只处理本次
相关改动。
**升级条件**:出现任何可观察行为变化 → 入口 2。
### 入口 2:单切片改动
会改变行为、不属于入口 3 的已知 bug,且同时满足以下**全部**条件的改动走这里,不入队、不建
ticket branch
- 只涉及一个模块,或只扩展一个已存在的公开接口
- 能在已存在的测试 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 或稳定知识文件。
## 正式工程主链
入口 4 使用以下顺序:
```text
setup-matt-pocock-skills
-> grill-with-docs (grilling + domain-modeling)
-> to-spec
-> to-tickets
-> main_loop.py enqueue
-> 提交 planning baseline
-> main_loop.py claim
-> 本地 ticket 执行协议 (tdd -> commit -> code-review -> finish)
-> main_loop.py integrate
-> 提交 final workflow state
```
- 每个仓库首次使用时运行 `setup-matt-pocock-skills`,本地开发选择 local markdown tracker
- 进入 `grill-with-docs` 或本地 ticket 执行协议前,重新读取 `memory-bank/project-brief.md`
`memory-bank/tech-context.md``memory-bank/system-patterns.md`
- `grilling` 必须走完整 design tree**design frontier**(尚未定下的决策集合)清空并经用户
确认后才进入 `to-spec`。该词与调度语义里的 ticket frontier 无关
- seam confirmation 的责任在 `to-spec``tdd`,不在 `grilling`——后者只收敛设计决策,不涉及
测试 seam。`to-spec` 必须按其原始流程与用户确认 seam 并写入 spec 的 Testing Decisions
`tdd` 不得在未经确认的 seam 上开始
- `to-spec` 不重新进行已经完成的需求采访
- `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by`
- 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue`
- 本批次全部 `enqueue` 成功后、任何 `claim` 之前,必须把对应的
`.scratch/<feature>/spec.md``.scratch/<feature>/issues/*.md``.scratch/queue.md`
提交为一个 planning baseline`to-spec``to-tickets``enqueue` 本身不隐式提交
- `codebase-design` 是 seam、deep module 与依赖分类的词汇来源,供 `to-spec``tdd` 查阅,
不作为独立会话运行
## On-ramps 与 detours
- 超过单个 session 可容纳的巨大、模糊工作先走 `wayfinder`;决策地图清晰后进入
`to-spec -> to-tickets`,不得从决策 ticket 直接跳到实现
- `research` 产出的高可信一手来源报告先进入 `grill-with-docs`,作为设计输入;调研不能
替代 grilling
- 已由 `to-tickets` 生成的 ticket 直接从 `main_loop.py claim -> 本地 ticket 执行协议` 开始,
不再 triage 或重复需求采访
## Phase boundaries
只在阶段边界判断去向,阶段中途不做这个决定。按以下顺序取首个满足项:
1. 下一阶段需要当前 session 作为 primary source,或剩余 smart zone 仍够下一阶段
(约 150k tokens)时,继续当前 session
2. 当前上下文与下一阶段无关时,使用 `clear`
3. 仅在跨 harness、跨目录/仓库、交给同事或 mid-phase 分出旁支任务时使用 `handoff`
4. 任务可独立 AFK 完成时交给 subagent
5. 其余同 harness、同目录且仍需当前上下文的情况使用 `compact`,并附上下一阶段要保留
什么的指令
上下文过长不等于必须 `handoff``handoff` 解决的是可移植性。`compact` 是决策树的默认落点,
但不是第一选择——除 continue 以外的每个选项都把 primary source 换成 secondary source。
`CONTEXT.md` 和 memory-bank 都不能替代阶段上下文。
## 本地 Ticket 执行协议
这是 Playbook 对 Matt 工程 skills 的调度适配层,只复用 `tdd``code-review`。尾部顺序与
上游的 `code-review -> commit` 相反:主循环要求证据绑定到固定 commit,所以先提交再 review。
对每个 claim 严格按以下顺序执行:
1. 读取已领取 ticket 的 spec、ticket、相关稳定知识和 ADR
2. 在已确认 seam 上按 `tdd` 完成 ticket 的可观察行为,并运行局部验证
3. 提交全部实现,使 ticket branch `HEAD` 成为固定、干净的审查点
4. 运行 `code-review`,显式提供 fixed point 和 Review 适配契约规定的需求来源
5. 修复硬 finding 后重新提交、验证和 review,直到两个 axis 均满足 pass 条件
6. 调用 `main_loop.py finish`,提交与当前 `HEAD`/base 绑定的结构化证据
## 文档职责
- `memory-bank/project-brief.md`:稳定项目定位、边界、目标和成功定义
- `memory-bank/tech-context.md`:技术栈、工具链、环境差异和验证入口
- `memory-bank/system-patterns.md`:当前模块边界、数据流、系统不变量和扩展路径
- `CONTEXT.md`:稳定领域词汇和定义,不记录 feature 状态
- `docs/adr/`:难以逆转且需要长期背景的真实技术取舍
- `.scratch/<feature>/spec.md`:单个 feature 的问题、方案、用户故事和测试决策
- `.scratch/<feature>/issues/*.md`:ticket DAG、验收标准和机器状态
- `.scratch/queue.md`feature 开发与集成顺序
- `docs/agents/*.md`tracker、领域文档布局和 skill 配置
不得手工修改 ticket 的 `Status``main-loop:ticket-state` 区块;只通过主循环变更。主循环
没有对应命令的状态组合按"卡死与恢复"处理,仍然不手工改。
## 稳定知识维护
只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和集成
状态只由 `.scratch/``main_loop.py` 维护。
- 项目定位、边界、目标或成功定义长期变化时,更新 `project-brief.md`
- 技术栈、工具链、环境差异或验证入口长期变化时,更新 `tech-context.md`;写入
`tech-context.md` 的命令和环境事实必须已经验证
- `system-patterns.md` 记录当前成立的架构;关键取舍及理由写入 `docs/adr/`
- `CONTEXT.md` 只记录稳定领域词汇和定义;不把聊天流水、未验证猜测或短期进度写入
`CONTEXT.md`
- 项目特有执行规则写入 `AGENT_RULES.local.md`
- `handoff` 的产物写入 OS 临时目录,不写入稳定知识文件
- 不为普通实现选择创建 ADR;没有长期价值的信息时不更新这些文件
## 调度语义
- feature 按 `.scratch/queue.md` 顺序调度
- 同一 feature 中 blocker 全部满足的 tickets 构成 ticket frontier,按稳定编号领取
- 同一 feature 的多个 frontier tickets 可在 worktree 模式并发执行
- 当前 feature 的 frontier 全被领取时返回 `BUSY`,不向后续 feature 扩张
- 只有前序 feature 无 frontier、无活动 claim 且确实 blocked 时,才可开发后序 feature
- 前序 feature 恢复后,新 claim 重新优先前序 feature
- 后序已领取 ticket 可以完成,但 feature 集成到 `main` 必须严格遵循队列顺序
- `resolved` 和显式 `skipped` 满足 blocker`claimed``blocked`
`ready-for-agent` 不满足
-`skipped` ticket 的 feature 标记为 partial,集成时必须显式授权
- 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``in-place` 串行执行不建额外 worktree`worktree` 每个 ticket 独立
branch/worktree,适用于多 session 并发;`auto` 无其他活动 claim 时用 in-place,否则用
worktree。计划并发时第一个 session 就必须指定 `worktree`
`BUSY` 的作用域是**整个队列,不限于同一 feature**,来源有三个:请求 `in-place` 而队列里存在
任何活动 claim;队列里存在任何 `in-place` 活动 claim(此时所有 isolation 的新 claim 都
BUSY);队首可调度 feature 的 frontier 已被领完。stale claim 也算活动 claim,必须显式
`reclaim``release-ticket` 才能腾出位置。同一 owner 恢复自己的 ticket 不受这些检查影响。
in-place 要求 checkout 无非 `.scratch` 改动、HEAD 非 detached、目标 branch 未被其他 worktree
占用。主循环不得自动 stash、reset、覆盖或丢弃改动。唯一例外:重新 claim 自己此前以 in-place
释放的同一 ticket 且 workspace/branch 都匹配时,跳过 dirty 检查以保留未提交改动。
worktree 模式下若 `feature/<slug>` 正被 control checkout 占用,主循环会先确认它干净、再把
control checkout 切到主干以释放该 branch。
`main_loop.py` 只支持 local Markdown tracker,要求所有 sessions 共享同一文件系统、control
checkout 和 Git common directory。远程 tracker 可以由 Matt skills 单独使用,但本主循环没有
远程 tracker adapter,不能接入远程 claim/finish 状态;跨机器或独立 clone 的并发不受支持。
## 主循环命令
主干 branch 名不是 `main` 时,`claim``integrate` 必须显式传 `--main-branch <name>`;其余
子命令不接触主干,也不需要 `--repo-root`
### 入队和状态
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py enqueue \
--state-root .scratch --feature <feature-slug>
python {{PLAYBOOK_SCRIPTS}}/main_loop.py status \
--state-root .scratch
```
`enqueue` 对已入队 feature 幂等,返回 `EXISTS=<slug>`
### 领取
`<owner>` 必须全局唯一且在本 session 内稳定,例如 `<agent>-<UTC timestamp>-<random>`
不得在并行 session 间复用 `codex``claude` 等通用名称;同一 owner 重复 claim 只用于原
session 恢复自己的 ticket,其他 owner 不得接管,失联时用显式 reclaim。
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py claim \
--state-root .scratch --repo-root . \
--owner "<owner>" --isolation in-place|worktree|auto \
[--main-branch main]
```
stdout 返回 8 个赋值行:`FEATURE``TICKET``CONTROL_ROOT` 是共享 Git control checkout
绝对 `STATE_ROOT` 是唯一状态目录,不必位于 `WORKSPACE` 内;`WORKSPACE` 是当前 ticket 的代码
工作区;其余为 `BRANCH``BASE``ISOLATION`。也可能返回 `NO FEATURES`
`NOOP: no claimable tickets``BUSY`,或 `INTEGRATION_REQUIRED=<slug>`(队首 feature 已
ready-to-integrate,必须先 `integrate`)。
领取成功后立即读取 `<STATE_ROOT>/<FEATURE>/spec.md`
`<STATE_ROOT>/<FEATURE>/issues/<TICKET>-*.md`;不得在 claim 前根据 frontier 猜测将领取哪个
ticket。后续 review 也必须用这两个已领取上下文,而不是 worktree 内相对 `.scratch` 的偶然
副本。
实现、提交和 review 在返回的 `WORKSPACE`/`BRANCH` 中完成;其余子命令一律用
`--state-root "<STATE_ROOT>"`,不得在 ticket worktree 中使用相对 `.scratch`。feature 集成
workspace 的路径不在 claim 输出里,只写在 `<STATE_ROOT>/<FEATURE>/.main-loop.json`
`integration_workspace`;纯 in-place 流程不创建该文件,feature 级操作在 `CONTROL_ROOT` 上做。
### 心跳和接管
claim 的 stale 租约固定为 30 分钟,调用者不得缩短。claim 存续期间至少每 10 分钟发送一次
heartbeat,并在耗时较长的验证、构建或 review 前后各发送一次。无法继续维持时,根据是否需要
保留阻塞原因分别执行下文完整的 `--result released``--result blocked --reason` 命令。
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py heartbeat \
--state-root "<STATE_ROOT>" \
--feature <feature> --ticket <NN> --owner "<owner>"
python {{PLAYBOOK_SCRIPTS}}/main_loop.py reclaim \
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
--feature <feature> --ticket <NN> --owner "<new-owner>"
```
reclaim 只接管 stale 的 `claimed` ticket,要求新 owner 与原 owner 不同,保留原 branch、
worktree 和未提交改动。被接管后原 owner 的 heartbeat 与 finish 立即报
`owned by another session`
### Review 适配契约
`code-review` 只产出 Standards 与 Spec 两段 findings,不给 pass/fail 判定,也不合并或
重排 findings。下面的 `pass` 映射是 Playbook 加的一层,由调用方把 findings 归结为判定。
调用时不得依赖其自动搜索或交互补问,必须显式提供:
- 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` 解析
只有对应 axis 零个未解决的硬 finding 时才能记录 `pass``standards=pass` 指没有未解决的
仓库标准违规(baseline smell 属 judgement call,须逐项记录已修复或带理由接受,不因被提出
就自动失败);`spec=pass` 指没有遗漏、部分实现、错误实现或未授权范围扩张。
任一来源缺失、Spec axis 被跳过、review 尚在询问输入,或仍有未解决的硬 finding 时,
不得据此填写 `standards=pass``spec=pass`。修复会改变 `HEAD`,必须重跑受影响验证和完整双轴
review,不能沿用旧报告。
### Ticket 完成或状态转换
`resolved` 前必须先提交实现,再按 Review 适配契约对 `BASE...HEAD` 运行 Matt
`code-review` 的 Standards/Spec 双轴审查。验证证据必须包含被验证 commit,review 证据必须
包含被审查 commit 和固定 base;任意非空文本或裸 `pass` 不能替代结构化证据。
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
--feature <feature> --ticket <NN> --owner "<owner>" \
--result resolved \
--implementation-commit <HEAD> \
--feature-head <已验证feature HEAD> --review-base <同一feature HEAD> \
--verified "<ticket-verification.json>" \
--reviewed "<ticket-review.json>"
```
验证和 review 参数必须是下文定义的 UTF-8 JSON artifact 路径,不接受内联 `pass` 文本。
首次 review base 是 claim 返回的 `BASE`。feature HEAD 已推进时返回
`RETRY: feature advanced` 和新的 `FEATURE_HEAD`;把该 `FEATURE_HEAD` 合入 ticket branch
重新运行受影响验证,并以它作为新的 review base 重跑双轴 review。重试时
`--feature-head``--review-base` 和 review evidence 的 `base` 都用该新值,验证与 review
evidence 的 `commit` 必须等于新的 ticket `HEAD`。只有成功集成到 feature branch 后 ticket
才变为 `resolved`
`finish` 不检查验收勾选框;验收由 `code-review` 的 Spec axis 负责,勾选框在 resolve 前
自行更新。
其他 `finish` 转换共用上面的 `--state-root``--repo-root``--feature``--ticket``--owner`
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
--feature <feature> --ticket <NN> --owner "<owner>" \
--result blocked --reason "<明确原因>"
python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
--feature <feature> --ticket <NN> --owner "<owner>" \
--result released
python {{PLAYBOOK_SCRIPTS}}/main_loop.py release-ticket \
--state-root "<STATE_ROOT>" --feature <feature> --ticket <NN> \
--reason "<原 session 无法恢复的原因>"
```
`blocked``skipped` 都必须提供 `--reason``released` 回到 `ready-for-agent` 并保留 workspace
只能由原 `claimed_by` 调用。`release-ticket` 用于原 session 已丢失、无人能 release 的 blocked
ticket;它不校验 owner,保留 branch、worktree 和未提交改动,返回
`TICKET_RELEASED=<feature>/<NN>`
### Feature 顺序集成
feature 必须吸收最新 `main`,完成 feature 验证、main 候选验证和最终双轴 review:
```bash
python {{PLAYBOOK_SCRIPTS}}/main_loop.py integrate \
--state-root "<STATE_ROOT>" --repo-root "<CONTROL_ROOT>" \
--feature <feature> --feature-head <已验证feature HEAD> \
--verified "<feature-verification.json>" \
--main-verified "<main-candidate-verification.json>" \
--reviewed "<feature-review.json>"
```
partial feature 还需 `--allow-partial`;未显式授权时报错而不是静默集成。
`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=`
`integrate` 成功后必须在主干提交本 feature 的最终 workflow state。只暂存
`.scratch/<feature>/` 下的持久变更,以及确由本次集成改写时的 `.scratch/queue.md`;不得用
`git add .scratch` 把其他活动 feature 的并发状态带入。该状态提交必须位于 feature merge commit
之后,不得 amend 或 squash 进 merge commit`.scratch/<feature>/.main-loop.json` 中记录的
`integration_commit` 必须继续指向主循环返回的 `MAIN_INTEGRATION_COMMIT`
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 与证据门禁
branch 命名:ticket 用 `ticket/<feature>/<NN>-<slug>`feature 用 `feature/<feature>`
执行顺序见"本地 Ticket 执行协议",证据参数见"主循环命令"。
局部 ticket 验证、feature 验证和 main 候选验证是三个独立门禁,不能互相替代。
`--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 | 触发条件 |
| ------------------------------- | -------------------------------------------- |
| `codebase-recon` | 架构、跨模块、重构、迁移或风险不明 |
| `brooks-audit` | 架构边界和长期维护性审查 |
| `codebase-design` | 需要 seam、deep module、依赖分类的词汇与判据 |
| `improve-codebase-architecture` | 修复落地后暴露出的结构问题 |
| `resolving-merge-conflicts` | feature 吸收 main 或集成时出现冲突 |
| `to-questionnaire` | 需要把待决问题整理成清单交回用户 |
| `commit-message` | 需要检查 staged diff 或生成提交信息 |
## 需要确认的场景
设计阶段由 `grilling` 收敛所有决策。执行 ticket 时仅在以下情况暂停确认:
- ticket/spec 仍存在会改变实现的真实歧义
- 需要 spec 未授权的行为、兼容性或架构取舍
- 需要破坏性操作、覆盖他人改动或扩大任务范围
入口 1、入口 2 范围内的改动和已批准 ticket 范围内的实现不重复需求采访。无交互模式按
"非交互模式下的入口 4"处理,不得替用户作设计决策。
## Session 收尾
- 运行与声明相匹配的 fresh verification
- 列出已完成、未完成、验证证据、风险和下一步
- 只提交当前 ticket/feature 相关改动,不混入其他 session 差异
- 工作未结束时按 Phase boundaries 决策树选择去向
---
**最后更新**{{DATE}}
处理首个实质性工程任务,或读取/修改 `.scratch` 中的 queue、ticket、heartbeat、integration
状态前,必须加载 `cook-it-through`。该 skill 独占上下文加载、任务分级、Matt Pocock 主链、
阶段切换、稳定知识、确认点、收尾和本地 Markdown ticket 主循环协议;主循环执行引擎随该
skill 安装并由它自行定位。非工程对话不要预加载该 skill。
<!-- playbook:rules:end -->
+25 -53
View File
@@ -24,14 +24,15 @@ playbook/
```text
templates/
├── AGENTS.template.md # 项目主入口与导航模板
├── AGENT_RULES.template.md # Matt Pocock ticket-native 执行规则模板
├── AGENT_RULES.template.md # 任务入口与按需 skill 路由模板
├── gitignore.template # .scratch 持久状态与本机运行时忽略规则
├── memory-bank/ # 稳定项目知识模板(3 个),不是机器状态源
├── cpp/ # C++ 工具链模板
└── python/ # Python 工具链模板
```
完整主链只在 `AGENT_RULES.template.md` 定义
`AGENT_RULES.template.md` 定义常驻边界和 Skill 入口;任务路由、完整主链与本地 Markdown
ticket 协议只在第一方 `skills/cook-it-through/` 定义,并在工程任务中按需加载。
## 模板分类
@@ -66,7 +67,8 @@ Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*`
### memory-bank/
`memory-bank/` 保存 grilling、设计和实现都需要的稳定项目知识。它不记录 owner、
heartbeat、ticket 或集成状态;机器状态只以 `.scratch/` `main_loop.py` 为准。
heartbeat、ticket 或集成状态;`.scratch/` 是唯一机器状态源,状态格式、转换语义和执行入口
由第一方 `cook-it-through` Skill 权威定义。
| 文件 | 用途 |
| ----------------------------- | ---------------------------------------------- |
@@ -76,23 +78,18 @@ heartbeat、ticket 或集成状态;机器状态只以 `.scratch/` 和 `main_lo
### AGENT_RULES.template.md
部署为项目的 `AGENT_RULES.md`是任务入口路由、Matt Pocock 工程主链、ticket 调度、
worktree、验证和集成协议的唯一流程权威。`<!-- playbook:rules:start/end -->` 区块内的
部署为项目的 `AGENT_RULES.md`只常驻指令优先级、项目边界和 Skill 加载入口。
`<!-- playbook:rules:start/end -->` 区块内的
内容由 Playbook 维护并在重新同步时刷新,项目补充写在区块外。项目私有规则写入
`AGENT_RULES.local.md`;该文件由项目维护,Playbook 不覆盖。
完整工程流程由 `cook-it-through` skill 按需定义;rules 只保留加载触发条件,不复制其协议。
### AGENTS.template.md
部署为项目的 `AGENTS.md`,只提供语言规则、核心规则和工程上下文导航。
标记区块的更新约束见下文“AGENTS 模板标记”。
## 任务入口
四个入口按成本递增,取第一个满足的:直接执行(零行为变更)、单切片改动(单模块、
已有 seam、一个 session 内)、已明确预期行为的 bug(`diagnosing-bugs` Phase 1-6)、
新 feature 或设计变更(完整主链)。边界不清时从单切片改动起步,触到升级条件再转入
完整主链,不要预付最重的流程。判据与升级条件见 `AGENT_RULES.template.md`
## 部署
```toml
@@ -108,49 +105,26 @@ langs = ["python"]
mode = "all"
```
部署后首次使用正式流程时运行 `setup-matt-pocock-skills`,并为本地主循环选择
local markdown tracker
本示例启用了 `[sync_rules]`,因此安装集合必须包含 `cook-it-through`
`[install_skills].mode = "list"` 时显式列出,`mode = "all"` 时不得通过 `exclude` 排除
只有不部署官方 `AGENT_RULES.md` 且不使用正式工程主链的安装场景,才可以排除该 skill。
同一配置违反该约束时,部署器会在写入任何同步文件前拒绝执行。
部署后首次使用正式流程时运行 `setup-matt-pocock-skills`,并为本地主循环选择 local Markdown
tracker;工程工作流与主循环协议只查该 skill。
## 正式开发流程
只有第四个入口(新 feature 或设计变更走这条链;前三个入口不入队、不建 ticket branch
新 feature 或设计变更走这条链;任务分级和是否升级到主链的判据只查 `cook-it-through`
```text
setup-matt-pocock-skills
-> grill-with-docs
-> to-spec
-> to-tickets
-> main_loop.py enqueue
-> 提交 planning baselinespec + tickets + queue
-> main_loop.py claim
-> 本地 ticket 执行协议(tdd
-> commit
-> code-review
-> main_loop.py finish
-> main_loop.py integrate
-> 提交 final workflow state
-> 按 cook-it-through 的本地 ticket 生命周期继续
```
产物职责
- `.scratch/<feature>/spec.md`feature spec
- `.scratch/<feature>/issues/*.md`ticket DAG 与执行状态
- `.scratch/queue.md`feature 开发和集成顺序
- `CONTEXT.md`:稳定领域词汇
- `docs/adr/`:长期架构决策
- `docs/agents/*.md`tracker 与领域文档配置
本批次 feature 全部 `enqueue` 后,先把对应 spec、tickets 和 queue 提交为一个 planning
baseline,再开始任何 `claim`。生成和入队步骤本身不隐式提交。
feature 集成成功后,再在主干提交该 feature 的最终 `.scratch` 持久状态。此提交位于 feature
merge commit 之后,并与其他活动 feature 的未提交状态隔离。
明确串行时可用 in-place,不创建 worktree;计划多个 session 并发时,第一个 claim
就指定 worktree。并发 sessions 必须共享同一文件系统与 Git common directory
`main_loop.py` 不支持跨机器、独立 clone 或远程 tracker adapter。Matt skills 可单独
使用远程 issue tracker,但不能把远程状态接入本主循环。完整隔离、恢复和证据协议见
`AGENT_RULES.template.md`
`to-tickets` 到 final workflow state 的格式、命令、产物职责、隔离、恢复、证据和集成顺序
只查 `cook-it-through`;本 README 不维护第二份流程定义。
## AGENTS 模板标记
@@ -159,7 +133,7 @@ merge commit 之后,并与其他活动 feature 的未提交状态隔离。
- `<!-- playbook:templates:start/end -->`:项目流程入口
`playbook:agents` 必须嵌在 `playbook:framework` 内。框架区块替换时,
`preserve_agents_subblock()` 依赖该嵌套保留项目按 `langs` 生成的语言入口。
该嵌套关系用于保留项目按 `langs` 生成的语言入口。
## 占位符
@@ -170,7 +144,6 @@ merge commit 之后,并与其他活动 feature 的未提交状态隔离。
| `{{DATE}}` | 同步日期 | 是 |
| `{{PROJECT_NAME}}` | 可选项目名 | 配置 `project_name` 时替换 |
| `{{PLAYBOOK_ROOT}}` | 项目内 Playbook 根目录 | 是 |
| `{{PLAYBOOK_SCRIPTS}}` | 项目内脚本目录 | 是 |
| `{{PROJECT_GOAL}}` | 项目目标 | 否,项目手工填写 |
| `{{PROJECT_DESCRIPTION}}` | 项目描述 | 否,项目手工填写 |
| 其他 `{{...}}` | 模板中的项目特定内容 | 否,项目手工填写或删除所在行 |
@@ -193,7 +166,10 @@ project/
```
`[install_skills]` 安装到 `agents_home/skills/`,通常不在项目目录内。`mode = "list"`
要求用户逐项列出 skills 及其依赖;`mode = "all"` 安装全部 skills。
要求用户逐项列出 skills 及其依赖;`mode = "all"` 安装全部未排除的 skills。两种模式都可用
`exclude = ["skill-name"]` 从本次安装集合排除同名 skill;该选项不会卸载已有目录。
同时启用 `[sync_rules]` 时,必须遵守上文对 `cook-it-through` 的保留约束。
`cook-it-through` 会随 skill 一起安装并包含其内部执行引擎,不依赖 snapshot 根目录下的脚本路径。
## 正式流程运行后按需产生的结构
@@ -201,7 +177,7 @@ project/
project/
├── CONTEXT.md # setup 后按需创建的稳定领域词汇
├── .scratch/
│ ├── queue.md # main_loop.py 维护的 feature 顺序
│ ├── queue.md # claim 优先级与严格的 feature 集成顺序
│ └── <feature>/
│ ├── spec.md # to-spec 产物
│ └── issues/ # to-tickets 产物与机器状态
@@ -209,7 +185,3 @@ project/
├── agents/ # setup 生成的 tracker/domain 配置
└── adr/ # grilling 过程中按需沉淀的长期决策
```
---
**最后更新**2026-08-07
+1196 -181
View File
File diff suppressed because it is too large Load Diff
+425
View File
@@ -0,0 +1,425 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MAIN_LOOP_SCRIPTS = ROOT / "skills" / "cook-it-through" / "scripts"
sys.path.insert(0, str(MAIN_LOOP_SCRIPTS))
from main_loop_scheduler import (
FeatureId,
FeatureRecord,
FeatureIntegrationId,
Scheduler,
SchedulerError,
TicketId,
TicketRecord,
parse_dependencies,
)
class SchedulerIdentityTests(unittest.TestCase):
def test_dependency_parser_accepts_only_canonical_qualified_identities(self):
owner = TicketId.parse("feature-a/04")
self.assertEqual(parse_dependencies("None", owner), ())
self.assertEqual(
parse_dependencies(
"feature-a/01; feature-b/03; feature-c@integrated",
owner,
),
(
TicketId.parse("feature-a/01"),
TicketId.parse("feature-b/03"),
FeatureIntegrationId.parse("feature-c@integrated"),
),
)
self.assertEqual(str(FeatureId.parse("feature-a")), "feature-a")
self.assertEqual(str(TicketId.parse("feature-a/01")), "feature-a/01")
self.assertEqual(
str(FeatureIntegrationId.parse("feature-a@integrated")),
"feature-a@integrated",
)
invalid_values = (
"01",
"01 - First",
"feature-a/01, feature-b/03",
"None - can start immediately",
"feature-a/1",
"Feature-A/01",
)
for raw in invalid_values:
with self.subTest(raw=raw), self.assertRaisesRegex(
SchedulerError,
"invalid dependency",
):
parse_dependencies(raw, owner)
with self.assertRaisesRegex(
SchedulerError,
"duplicate dependency feature-a/01",
):
parse_dependencies("feature-a/01; feature-a/01", owner)
with self.assertRaisesRegex(
SchedulerError,
"self dependency feature-a/04",
):
parse_dependencies("feature-a/04", owner)
class GlobalSchedulerTests(unittest.TestCase):
def test_scheduler_rejects_an_invalid_ticket_status_with_qualified_identity(self):
feature = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="invalid",
status="done",
dependencies=(),
),
),
)
with self.assertRaisesRegex(
SchedulerError,
"alpha/01: invalid status done",
):
Scheduler((feature,))
def test_scheduler_rejects_impossible_integration_state(self):
unresolved_integrated = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="unfinished",
status="ready-for-agent",
dependencies=(),
),
),
integrated=True,
)
with self.assertRaisesRegex(
SchedulerError,
"alpha@integrated: unsatisfied ticket alpha/01",
):
Scheduler((unresolved_integrated,))
alpha = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="pending",
status="resolved",
dependencies=(),
),
),
)
beta = FeatureRecord(
id=FeatureId.parse("beta"),
tickets=(
TicketRecord(
id=TicketId.parse("beta/01"),
slug="done",
status="resolved",
dependencies=(),
),
),
integrated=True,
)
with self.assertRaisesRegex(
SchedulerError,
"beta@integrated: earlier integration is pending: alpha@integrated",
):
Scheduler((alpha, beta))
def test_claimable_tickets_use_queue_priority_without_a_feature_head_barrier(self):
alpha = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="alpha-ticket",
status="claimed",
dependencies=(),
),
),
)
beta = FeatureRecord(
id=FeatureId.parse("beta"),
tickets=(
TicketRecord(
id=TicketId.parse("beta/02"),
slug="later-number",
status="ready-for-agent",
dependencies=(),
),
TicketRecord(
id=TicketId.parse("beta/01"),
slug="earlier-number",
status="ready-for-agent",
dependencies=(),
),
),
)
scheduler = Scheduler((alpha, beta))
self.assertEqual(
scheduler.ticket_frontier,
(TicketId.parse("beta/01"), TicketId.parse("beta/02")),
)
def test_ticket_and_integration_dependencies_have_distinct_satisfaction_rules(self):
beta_id = FeatureId.parse("beta")
alpha_ticket = TicketRecord(
id=TicketId.parse("alpha/01"),
slug="consumer",
status="ready-for-agent",
dependencies=(
TicketId.parse("beta/01"),
FeatureIntegrationId.parse("beta@integrated"),
),
)
beta = FeatureRecord(
id=beta_id,
tickets=(
TicketRecord(
id=TicketId.parse("beta/01"),
slug="provider",
status="skipped",
dependencies=(),
),
),
)
alpha = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(alpha_ticket,),
)
before_integration = Scheduler((beta, alpha))
self.assertEqual(before_integration.ticket_frontier, ())
self.assertEqual(
before_integration.integration_dependencies(alpha_ticket.id),
(FeatureIntegrationId.parse("beta@integrated"),),
)
self.assertEqual(
before_integration.unsatisfied_dependencies(alpha_ticket.id),
(FeatureIntegrationId.parse("beta@integrated"),),
)
after_integration = Scheduler(
(
FeatureRecord(
id=beta.id,
tickets=beta.tickets,
integrated=True,
),
alpha,
)
)
self.assertEqual(after_integration.ticket_frontier, (alpha_ticket.id,))
resolved_only = Scheduler(
(
FeatureRecord(
id=beta.id,
tickets=(
TicketRecord(
id=TicketId.parse("beta/01"),
slug="provider",
status="resolved",
dependencies=(),
),
),
),
FeatureRecord(
id=alpha.id,
tickets=(
TicketRecord(
id=alpha_ticket.id,
slug=alpha_ticket.slug,
status=alpha_ticket.status,
dependencies=(TicketId.parse("beta/01"),),
),
),
),
)
)
self.assertEqual(resolved_only.ticket_frontier, (alpha_ticket.id,))
def test_graph_validation_reports_missing_targets_and_cross_feature_cycles(self):
missing_feature = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="missing-feature",
status="ready-for-agent",
dependencies=(TicketId.parse("missing/01"),),
),
),
)
with self.assertRaisesRegex(
SchedulerError,
"alpha/01: dependency feature not queued: missing",
):
Scheduler((missing_feature,))
beta = FeatureRecord(
id=FeatureId.parse("beta"),
tickets=(
TicketRecord(
id=TicketId.parse("beta/01"),
slug="present",
status="ready-for-agent",
dependencies=(),
),
),
)
missing_ticket = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="missing-ticket",
status="ready-for-agent",
dependencies=(TicketId.parse("beta/99"),),
),
),
)
with self.assertRaisesRegex(
SchedulerError,
"alpha/01: dependency ticket not found: beta/99",
):
Scheduler((alpha := missing_ticket, beta))
cycle_alpha = FeatureRecord(
id=alpha.id,
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="cycle-a",
status="ready-for-agent",
dependencies=(TicketId.parse("beta/02"),),
),
),
)
cycle_beta = FeatureRecord(
id=beta.id,
tickets=(
TicketRecord(
id=TicketId.parse("beta/02"),
slug="cycle-b",
status="ready-for-agent",
dependencies=(
FeatureIntegrationId.parse("alpha@integrated"),
),
),
),
)
with self.assertRaisesRegex(
SchedulerError,
"dependency cycle: alpha/01 -> beta/02 -> alpha@integrated -> alpha/01",
):
Scheduler((cycle_alpha, cycle_beta))
def test_ready_integration_frontier_does_not_hide_later_ticket_frontier(self):
alpha = FeatureRecord(
id=FeatureId.parse("alpha"),
tickets=(
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="done",
status="resolved",
dependencies=(),
),
),
)
beta = FeatureRecord(
id=FeatureId.parse("beta"),
tickets=(
TicketRecord(
id=TicketId.parse("beta/01"),
slug="independent",
status="ready-for-agent",
dependencies=(),
),
),
)
scheduler = Scheduler((alpha, beta))
self.assertEqual(
scheduler.integration_frontier,
FeatureIntegrationId.parse("alpha@integrated"),
)
self.assertEqual(
scheduler.ticket_frontier,
(TicketId.parse("beta/01"),),
)
self.assertEqual(scheduler.feature_state(alpha.id), "ready-to-integrate")
self.assertEqual(scheduler.feature_state(beta.id), "queued")
def test_ticket_input_order_does_not_change_frontier_or_error_order(self):
tickets = (
TicketRecord(
id=TicketId.parse("alpha/10"),
slug="ten",
status="ready-for-agent",
dependencies=(),
),
TicketRecord(
id=TicketId.parse("alpha/02"),
slug="two",
status="ready-for-agent",
dependencies=(),
),
)
forward = Scheduler(
(FeatureRecord(id=FeatureId.parse("alpha"), tickets=tickets),)
)
reverse = Scheduler(
(FeatureRecord(id=FeatureId.parse("alpha"), tickets=tickets[::-1]),)
)
expected = (TicketId.parse("alpha/02"), TicketId.parse("alpha/10"))
self.assertEqual(forward.ticket_frontier, expected)
self.assertEqual(reverse.ticket_frontier, expected)
invalid_tickets = (
TicketRecord(
id=TicketId.parse("alpha/02"),
slug="second-error",
status="ready-for-agent",
dependencies=(TicketId.parse("missing/02"),),
),
TicketRecord(
id=TicketId.parse("alpha/01"),
slug="first-error",
status="ready-for-agent",
dependencies=(TicketId.parse("missing/01"),),
),
)
messages = []
for order in (invalid_tickets, invalid_tickets[::-1]):
with self.assertRaises(SchedulerError) as raised:
Scheduler(
(FeatureRecord(id=FeatureId.parse("alpha"), tickets=order),)
)
messages.append(str(raised.exception))
self.assertEqual(
messages,
[
"alpha/01: dependency feature not queued: missing",
"alpha/01: dependency feature not queued: missing",
],
)
if __name__ == "__main__":
unittest.main()
+228 -11
View File
@@ -52,7 +52,7 @@ def copy_subtree_source(destination: Path) -> None:
)
(destination / "skills").mkdir()
for name in ("commit-message",):
for name in ("commit-message", "cook-it-through"):
shutil.copytree(
ROOT / "skills" / name,
destination / "skills" / name,
@@ -84,7 +84,7 @@ no_backup = true
[install_skills]
agents_home = ".test-agents"
mode = "list"
skills = ["commit-message"]
skills = ["commit-message", "cook-it-through"]
no_backup = true
""".lstrip(),
encoding="utf-8",
@@ -130,7 +130,7 @@ class PlaybookDeploymentTests(unittest.TestCase):
self.assertIn("-h, --help", result.stdout)
self.assertNotIn("-h, -help", result.stdout)
def test_install_all_excludes_legacy_superpowers_skills(self):
def test_install_all_honors_configured_and_legacy_exclusions(self):
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
project_root.mkdir()
@@ -145,6 +145,7 @@ install_mode = "snapshot"
[install_skills]
agents_home = ".test-agents"
mode = "all"
exclude = ["cook-it-through", "to-tickets"]
no_backup = true
""".lstrip(),
encoding="utf-8",
@@ -164,7 +165,8 @@ no_backup = true
if path.is_dir()
}
self.assertIn("grill-with-docs", installed)
self.assertIn("to-tickets", installed)
self.assertNotIn("to-tickets", installed)
self.assertNotIn("cook-it-through", installed)
self.assertTrue(
{
"using-superpowers",
@@ -174,6 +176,97 @@ no_backup = true
}.isdisjoint(installed)
)
def test_sync_rules_rejects_missing_workflow_skill_before_any_write(self):
invalid_install_configs = {
"excluded": '\n'.join(
(
'mode = "all"',
'exclude = ["cook-it-through"]',
)
),
"omitted-from-list": '\n'.join(
(
'mode = "list"',
'skills = ["commit-message"]',
)
),
}
for case, install_config in invalid_install_configs.items():
with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
project_root.mkdir()
config = project_root / "playbook.toml"
config.write_text(
f"""
[playbook]
project_root = "."
playbook_root = "custom/playbook"
install_mode = "snapshot"
[sync_rules]
no_backup = true
[install_skills]
agents_home = ".test-agents"
{install_config}
no_backup = true
""".lstrip(),
encoding="utf-8",
newline="\n",
)
result = run_playbook(SCRIPT, config, project_root)
self.assertEqual(result.returncode, 2, msg=result.stdout)
self.assertIn("cook-it-through", result.stderr)
for untouched in (
"custom/playbook",
"AGENTS.md",
"AGENT_RULES.md",
"AGENT_RULES.local.md",
".test-agents",
):
self.assertFalse(
(project_root / untouched).exists(),
msg=f"invalid config wrote {untouched}",
)
def test_sync_rules_requires_an_install_skills_action_before_any_write(self):
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
project_root.mkdir()
config = project_root / "playbook.toml"
config.write_text(
"""
[playbook]
project_root = "."
playbook_root = "custom/playbook"
install_mode = "snapshot"
[sync_rules]
no_backup = true
""".lstrip(),
encoding="utf-8",
newline="\n",
)
result = run_playbook(SCRIPT, config, project_root)
self.assertEqual(result.returncode, 2, msg=result.stdout)
self.assertIn("[install_skills]", result.stderr)
self.assertIn("cook-it-through", result.stderr)
for untouched in (
"custom/playbook",
"AGENTS.md",
"AGENT_RULES.md",
"AGENT_RULES.local.md",
):
self.assertFalse(
(project_root / untouched).exists(),
msg=f"invalid config wrote {untouched}",
)
def test_install_skills_list_requires_explicit_skills(self):
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
@@ -205,6 +298,46 @@ no_backup = true
)
self.assertIn("ERROR: skills is required", result.stderr)
def test_install_skills_accepts_an_empty_exclusion_list(self):
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
project_root.mkdir()
config = project_root / "playbook.toml"
config.write_text(
"""
[playbook]
project_root = "."
playbook_root = "custom/playbook"
install_mode = "snapshot"
[install_skills]
agents_home = ".test-agents"
mode = "list"
skills = ["commit-message"]
exclude = []
no_backup = true
""".lstrip(),
encoding="utf-8",
newline="\n",
)
result = run_playbook(SCRIPT, config, project_root)
self.assertEqual(
result.returncode,
0,
msg=f"empty exclusion failed\n{result.stdout}{result.stderr}",
)
self.assertTrue(
(
project_root
/ ".test-agents"
/ "skills"
/ "commit-message"
/ "SKILL.md"
).is_file()
)
def test_invalid_toml_is_reported_without_a_traceback(self):
with tempfile.TemporaryDirectory() as tmp_dir:
project_root = Path(tmp_dir) / "project"
@@ -269,6 +402,14 @@ project_root = "C:\workspace\project"
".test-agents/skills/commit-message/SKILL.md",
".test-agents/skills/commit-message/references/commit_policy.json",
".test-agents/skills/commit-message/scripts/validate_commit_message.py",
".test-agents/skills/cook-it-through/SKILL.md",
".test-agents/skills/cook-it-through/rules/session-boundary.md",
".test-agents/skills/cook-it-through/scripts/main_loop.py",
".test-agents/skills/cook-it-through/scripts/main_loop_scheduler.py",
".test-agents/skills/cook-it-through/workflows/single-session.md",
".test-agents/skills/cook-it-through/workflows/feature-planning.md",
".test-agents/skills/cook-it-through/workflows/ticket-execution.md",
".test-agents/skills/cook-it-through/workflows/feature-integration.md",
)
missing = [
path
@@ -331,6 +472,40 @@ project_root = "C:\workspace\project"
).read_text(encoding="utf-8"),
)
source_main_loop_root = ROOT / "skills/cook-it-through"
installed_main_loop_root = (
project_root / ".test-agents/skills/cook-it-through"
)
for relative_path in (
"SKILL.md",
"rules/session-boundary.md",
"scripts/main_loop.py",
"scripts/main_loop_scheduler.py",
"workflows/single-session.md",
"workflows/feature-planning.md",
"workflows/ticket-execution.md",
"workflows/feature-integration.md",
):
self.assertEqual(
(installed_main_loop_root / relative_path).read_bytes(),
(source_main_loop_root / relative_path).read_bytes(),
)
installed_help = subprocess.run(
[
sys.executable,
str(installed_main_loop_root / "scripts/main_loop.py"),
"--help",
],
cwd=project_root,
capture_output=True,
text=True,
)
self.assertEqual(
installed_help.returncode, 0, msg=installed_help.stderr
)
self.assertIn("enqueue", installed_help.stdout)
rules_text = (project_root / "AGENT_RULES.md").read_text(
encoding="utf-8"
)
@@ -338,6 +513,28 @@ project_root = "C:\workspace\project"
f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录",
rules_text,
)
self.assertIn("`cook-it-through`", rules_text)
self.assertNotIn("**Blocked by:**", rules_text)
installed_main_loop_text = "\n".join(
(installed_main_loop_root / relative_path).read_text(
encoding="utf-8"
)
for relative_path in (
"SKILL.md",
"rules/session-boundary.md",
"workflows/single-session.md",
"workflows/feature-planning.md",
"workflows/ticket-execution.md",
"workflows/feature-integration.md",
)
)
for contract_fragment in (
"`TicketId`qualified `feature-slug/NN`",
"`FeatureIntegrationId``feature-slug@integrated`",
"**Blocked by:** None",
"**Blocked by:** feature-a/01; feature-b@integrated",
):
self.assertIn(contract_fragment, installed_main_loop_text)
gitignore_text = (project_root / ".gitignore").read_text(
encoding="utf-8"
)
@@ -355,15 +552,27 @@ project_root = "C:\workspace\project"
self.assertIn("/.scratch/worktrees/", gitignore_text)
self.assertIn("/.scratch/**/*.tmp", gitignore_text)
deployed_playbook_root = project_root / playbook_root
deployed_main_loop_root = (
deployed_playbook_root / "skills/cook-it-through/scripts"
)
self.assertTrue((deployed_main_loop_root / "main_loop.py").is_file())
self.assertTrue(
(deployed_main_loop_root / "main_loop_scheduler.py").is_file()
)
self.assertFalse(
(deployed_playbook_root / "scripts/main_loop.py").exists()
)
self.assertFalse(
(deployed_playbook_root / "scripts/main_loop_scheduler.py").exists()
)
if install_mode == "snapshot":
snapshot_root = project_root / playbook_root
snapshot_root = deployed_playbook_root
self.assertTrue((snapshot_root / "SOURCE.md").is_file())
self.assertTrue(
(snapshot_root / "scripts/playbook.py").is_file()
)
self.assertTrue(
(snapshot_root / "scripts/main_loop.py").is_file()
)
self.assertTrue(
(snapshot_root / "playbook.example.toml").is_file()
)
@@ -495,7 +704,9 @@ no_backup = true
# A project appendix outside the block, and drift inside it.
appendix = "\n## 项目补充\n\n保留这段项目自己的说明。\n"
drifted = seeded.replace("## 任务入口", "## 任务入口(本地改过)") + appendix
drifted = seeded.replace(
"## 工作流入口", "## 工作流入口(本地改过)"
) + appendix
rules_md.write_text(drifted, encoding="utf-8", newline="\n")
resync = run_playbook(SCRIPT, config, project_root)
@@ -510,11 +721,11 @@ no_backup = true
msg="content outside the block belongs to the project",
)
self.assertIn(
"## 任务入口\n",
"## 工作流入口\n",
after,
msg="the process itself is playbook-owned and must be refreshed",
)
self.assertNotIn("## 任务入口(本地改过)", after)
self.assertNotIn("## 工作流入口(本地改过)", after)
self.assertEqual(after.count("<!-- playbook:rules:start -->"), 1)
legacy = project_root / "legacy" / "AGENT_RULES.md"
@@ -535,6 +746,12 @@ install_mode = "snapshot"
[sync_rules]
date = "2026-01-01"
no_backup = true
[install_skills]
agents_home = ".test-agents"
mode = "list"
skills = ["cook-it-through"]
no_backup = true
""".lstrip(),
encoding="utf-8",
newline="\n",
+440 -336
View File
@@ -8,9 +8,23 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = ROOT / "templates"
MAIN_LOOP_ROOT = ROOT / "skills" / "cook-it-through"
MAIN_LOOP_SKILL = MAIN_LOOP_ROOT / "SKILL.md"
MAIN_LOOP_SESSION_BOUNDARY = MAIN_LOOP_ROOT / "rules" / "session-boundary.md"
MAIN_LOOP_WORKFLOWS = {
name: MAIN_LOOP_ROOT / "workflows" / f"{name}.md"
for name in (
"single-session",
"feature-planning",
"ticket-execution",
"feature-integration",
)
}
MAIN_LOOP_SCRIPTS = MAIN_LOOP_ROOT / "scripts"
MAIN_LOOP_SCRIPT = MAIN_LOOP_SCRIPTS / "main_loop.py"
_MAIN_LOOP_SPEC = importlib.util.spec_from_file_location(
"playbook_main_loop_contracts", ROOT / "scripts" / "main_loop.py"
"playbook_main_loop_contracts", MAIN_LOOP_SCRIPT
)
assert _MAIN_LOOP_SPEC and _MAIN_LOOP_SPEC.loader
MAIN_LOOP = importlib.util.module_from_spec(_MAIN_LOOP_SPEC)
@@ -36,6 +50,19 @@ def required_flags(parser: argparse.ArgumentParser) -> set[str]:
}
def option_action(
parser: argparse.ArgumentParser, option: str
) -> argparse.Action:
for action in parser._actions: # noqa: SLF001
if option in action.option_strings:
return action
raise AssertionError(f"{parser.prog} exposes no {option}")
def normalized_prose(text: str) -> str:
return " ".join(text.split())
def isolation_choices() -> set[str]:
claim = subcommand_parsers()["claim"]
for action in claim._actions: # noqa: SLF001
@@ -59,6 +86,32 @@ def rules_text() -> str:
return (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
def main_loop_skill_text() -> str:
return MAIN_LOOP_SKILL.read_text(encoding="utf-8")
def main_loop_session_boundary_text() -> str:
return MAIN_LOOP_SESSION_BOUNDARY.read_text(encoding="utf-8")
def main_loop_workflow_text(name: str) -> str:
return MAIN_LOOP_WORKFLOWS[name].read_text(encoding="utf-8")
def main_loop_instruction_paths() -> tuple[Path, ...]:
return (
MAIN_LOOP_SKILL,
MAIN_LOOP_SESSION_BOUNDARY,
*MAIN_LOOP_WORKFLOWS.values(),
)
def main_loop_bundle_text() -> str:
return "\n".join(
path.read_text(encoding="utf-8") for path in main_loop_instruction_paths()
)
def section(text: str, heading: str, until: str) -> str:
return text.split(heading, 1)[1].split(until, 1)[0]
@@ -112,8 +165,10 @@ LEGACY_FLOW_TERMS = (
class TemplateContractsTests(unittest.TestCase):
def test_templates_define_only_the_matt_ticket_workflow(self):
combined = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted(TEMPLATES.rglob("*.md"))
[
*(path.read_text(encoding="utf-8") for path in sorted(TEMPLATES.rglob("*.md"))),
main_loop_bundle_text(),
]
)
for required in (
@@ -136,7 +191,8 @@ class TemplateContractsTests(unittest.TestCase):
self.assertNotIn("docs/workflows/", templates_readme)
self.assertNotIn("templates/workflows/", templates_readme)
self.assertNotIn("docs/superpowers/", templates_readme)
self.assertIn(".scratch/<feature>/spec.md", templates_readme)
self.assertNotIn(".scratch/<feature>/spec.md", templates_readme)
self.assertIn("`.scratch/<feature>/spec.md`", main_loop_bundle_text())
self.assertNotIn("docs/prompts/", templates_readme)
agents_template = (TEMPLATES / "AGENTS.template.md").read_text(
@@ -206,6 +262,44 @@ class TemplateContractsTests(unittest.TestCase):
self.assertNotIn("force", classification)
self.assertNotIn("no_backup", classification)
def test_templates_readme_separates_state_source_and_protocol_authority(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
normalized = normalized_prose(templates_readme)
self.assertIn("`.scratch/` 是唯一机器状态源", normalized)
self.assertIn(
"由第一方 `cook-it-through` Skill 权威定义",
normalized,
)
self.assertNotIn("preserve_agents_subblock()", templates_readme)
self.assertNotIn("四个入口按成本递增", templates_readme)
self.assertIsNone(
re.search(r"\*\*最后更新\*\*\d{4}-\d{2}-\d{2}", templates_readme),
msg="templates README must not carry a hand-maintained update date",
)
def test_templates_readme_documents_skill_exclusion_boundary(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
deployment = normalized_prose(
section(templates_readme, "## 部署", "## 正式开发流程")
)
layout = normalized_prose(
section(
templates_readme,
"## `playbook.py` 部署后结构",
"## 正式流程运行后按需产生的结构",
)
)
self.assertIn("启用了 `[sync_rules]`", deployment)
self.assertIn("安装集合必须包含 `cook-it-through`", deployment)
self.assertIn('`mode = "all"` 时不得通过 `exclude` 排除', deployment)
self.assertIn(
"只有不部署官方 `AGENT_RULES.md` 且不使用正式工程主链的安装场景,才可以排除该 skill",
deployment,
)
self.assertIn("同时启用 `[sync_rules]` 时,必须遵守上文", layout)
def test_memory_bank_contains_only_stable_project_knowledge(self):
memory_templates = {
path.name for path in (TEMPLATES / "memory-bank").glob("*.template.md")
@@ -219,67 +313,153 @@ class TemplateContractsTests(unittest.TestCase):
},
)
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
stable_paths = (
boundary = normalized_prose(main_loop_session_boundary_text())
planning = main_loop_workflow_text("feature-planning")
execution = main_loop_workflow_text("ticket-execution")
for path in (
"memory-bank/project-brief.md",
"memory-bank/tech-context.md",
"memory-bank/system-patterns.md",
)
for path in stable_paths:
self.assertIn(path, rules)
self.assertIn("进入 `grill-with-docs` 或本地 ticket 执行协议前", rules)
normalized_rules = " ".join(rules.split())
for required in (
"只记录下一 session 仍需要的稳定知识",
"写入 `tech-context.md` 的命令和环境事实必须已经验证",
"关键取舍及理由写入 `docs/adr/`",
"不把聊天流水、未验证猜测或短期进度写入 `CONTEXT.md`",
"没有长期价值的信息时不更新这些文件",
):
self.assertIn(required, normalized_rules)
self.assertIn(path, boundary)
for required in (
"已经验证且可复现",
"重新发现成本高",
"不能从代码直接看出",
"下一 session 仍需要",
"关键取舍及理由写入 `docs/adr/`",
"`handoff` 产物写入 OS 临时目录",
):
self.assertIn(required, boundary)
self.assertIn("进入 `grill-with-docs` 前", planning)
self.assertIn("领取后实现前", execution)
def test_prompt_templates_are_not_part_of_the_workflow(self):
self.assertFalse(TEMPLATES.joinpath("prompts").exists())
def test_agent_rules_template_defines_ticket_and_integration_contracts(self):
def test_agent_rules_routes_main_loop_work_to_the_firstparty_skill(self):
rules = rules_text()
normalized = " ".join(rules.split())
normalized = normalized_prose(rules)
self.assertIn("{{PLAYBOOK_ROOT}}", rules)
self.assertIn("{{PLAYBOOK_SCRIPTS}}", rules)
for heading in (
"## 任务入口",
"## 正式工程主链",
"## On-ramps 与 detours",
"## Phase boundaries",
"## 本地 Ticket 执行协议",
"## 文档职责",
"## 调度语义",
"## 执行隔离",
"## 主循环命令",
"## Git 与证据门禁",
"## 辅助能力",
"## Session 收尾",
):
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}")
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules)
self.assertEqual(
headings(rules),
["优先级", "沟通", "项目边界", "工作流入口"],
msg="always-loaded rules must remain a thin workflow bootstrap",
)
self.assertIn("必须加载 `cook-it-through`", rules)
self.assertIn(
"或读取/修改 `.scratch` 中的 queue、ticket、heartbeat、integration 状态前",
normalized,
)
self.assertIn("该 skill 独占", rules)
self.assertIn("主循环执行引擎随该 skill 安装", normalized)
self.assertIn("`.agents/index.md`", rules)
self.assertNotIn("**Blocked by:**", rules)
self.assertNotIn("## 主循环命令", rules)
self.assertLessEqual(len(rules.splitlines()), 50)
self.assertLessEqual(len(rules.encode("utf-8")), 5_000)
for legacy in LEGACY_FLOW_TERMS:
self.assertNotIn(legacy, rules)
def test_cook_it_through_skill_owns_the_ticket_contract(self):
rules = rules_text()
bundle = main_loop_bundle_text()
planning = main_loop_workflow_text("feature-planning")
readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
skills_readme = (ROOT / "skills/README.md").read_text(encoding="utf-8")
self.assertFalse((ROOT / "docs/common/main-loop-ticket-contract.md").exists())
self.assertEqual(
{
path.relative_to(MAIN_LOOP_ROOT).as_posix()
for path in MAIN_LOOP_ROOT.rglob("*")
if path.is_file() and "__pycache__" not in path.parts
},
{
"SKILL.md",
"rules/session-boundary.md",
"workflows/single-session.md",
"workflows/feature-planning.md",
"workflows/ticket-execution.md",
"workflows/feature-integration.md",
"scripts/main_loop.py",
"scripts/main_loop_scheduler.py",
},
)
self.assertFalse((ROOT / "scripts/main_loop.py").exists())
self.assertFalse((ROOT / "scripts/main_loop_scheduler.py").exists())
for required in (
"`TicketId`qualified `feature-slug/NN`",
"`FeatureIntegrationId``feature-slug@integrated`",
"**Blocked by:** None",
"**Blocked by:** feature-a/01; feature-b@integrated",
"同批重复 `--feature`",
"hard cut",
):
self.assertIn(required, planning)
self.assertIn("第三方 `to-tickets` 只定义通用 tracker 行为", planning)
self.assertIn("最终机器校验边界", planning)
self.assertIn("手工修改 ticket `Status`", bundle)
self.assertIn("<COOK_IT_THROUGH_ROOT>/scripts/main_loop.py", bundle)
self.assertNotIn("<PLAYBOOK_SCRIPTS>", bundle)
self.assertIn("`cook-it-through`", rules)
self.assertNotIn("**Blocked by:**", rules)
self.assertIn("只在第一方 `skills/cook-it-through/` 定义", readme)
self.assertNotIn("main-loop-ticket-contract.md", readme)
self.assertNotIn("**Blocked by:** feature-a/01; feature-b@integrated", readme)
for public_readme in (readme, skills_readme):
self.assertNotIn("main_loop.py", public_readme)
self.assertNotIn("--isolation", readme)
def test_cook_it_through_uses_routed_progressive_disclosure(self):
skill = main_loop_skill_text()
bundle = main_loop_bundle_text()
description = next(
line for line in skill.splitlines() if line.startswith("description:")
)
self.assertLessEqual(len(skill.splitlines()), 85)
self.assertLessEqual(len(skill.encode("utf-8")), 8_000)
self.assertLessEqual(sum(len(p.read_text().splitlines()) for p in main_loop_instruction_paths()), 330)
self.assertIn("main_loop.py <command> --help", skill)
self.assertNotIn("```bash", bundle)
self.assertNotIn("入口 1", description)
for command in subcommand_parsers():
self.assertNotIn(command, description)
for negative_boundary in ("纯 TSL 语法/API 查询", "commit message", "远端 Gitea CI"):
self.assertIn(negative_boundary, description)
for path in main_loop_instruction_paths()[1:]:
relative = path.relative_to(MAIN_LOOP_ROOT).as_posix()
other_text = "\n".join(
candidate.read_text(encoding="utf-8")
for candidate in main_loop_instruction_paths()
if candidate != path
)
self.assertIn(relative, other_text, msg=f"unrouted instruction file: {relative}")
self.assertNotIn("FILL:", bundle)
def test_cook_it_through_keeps_irrecoverable_red_lines_resident(self):
skill = normalized_prose(main_loop_skill_text())
rules = rules_text()
for required in (
"禁止手工修改 ticket `Status`",
"禁止伪造或复用证据 artifact",
"integration dependency 不可见时禁止继续",
"禁止 stash、reset 或覆盖其他 session 改动",
"远程 tracker、独立 clone 或跨机器状态",
):
self.assertIn(required, skill)
for migrated_rule in (
"main-loop:ticket-state",
"integration frontier",
"远程 tracker、独立 clone、跨机器状态",
):
self.assertNotIn(migrated_rule, rules)
def test_gitignore_template_tracks_scratch_and_ignores_only_runtime(self):
template = (TEMPLATES / "gitignore.template").read_text(encoding="utf-8")
for durable_rule in ("!/.scratch/", "!/.scratch/**"):
self.assertIn(durable_rule, template)
for runtime_rule in (
@@ -288,40 +468,63 @@ class TemplateContractsTests(unittest.TestCase):
"/.scratch/**/*.tmp",
):
self.assertIn(runtime_rule, template)
self.assertNotIn("是否纳入版本控制由项目决定", rules_text())
rules = rules_text()
self.assertNotIn("是否纳入版本控制由项目决定", rules)
def test_cook_it_through_routes_entries_and_blast_radius_floor(self):
skill = main_loop_skill_text()
entries = section(skill, "## 任务路由", "## 常驻红线")
entry_headings = [h for h in headings(entries) if h.startswith("入口 ")]
self.assertEqual(
entry_headings,
[
"入口 1:直接执行",
"入口 2:单切片改动",
"入口 3:已明确预期行为的 bug",
"入口 4:新 feature 或设计变更",
],
)
self.assertEqual(entries.count("**升级条件**"), 2)
self.assertIn("AGENT_RULES.local.md", entries)
self.assertIn("高爆炸半径路径", entries)
self.assertIn("构建、CI 或分发配置", entries)
self.assertIn("最低入口 2", entries)
self.assertIn("入口 1 不加载按需文件", entries)
entry_two = normalized_prose(section(entries, "### 入口 2", "### 入口 3"))
entry_four = normalized_prose(entries.split("### 入口 4", 1)[1])
self.assertIn("不属于入口 3", entry_two)
self.assertIn("入口 4", entry_two)
self.assertIn("边界不清时先按入口 2 起步", entry_four)
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("`to-spec -> to-tickets`", on_ramps)
self.assertIn("`research`", on_ramps)
self.assertIn("先进入 `grill-with-docs`", on_ramps)
self.assertNotIn("`prototype`", on_ramps)
bug_route = section(rules, "### 入口 3", "### 入口 4")
self.assertIn("`diagnosing-bugs` 完整执行 Phase 1-6", bug_route)
self.assertNotIn("to-spec", bug_route)
self.assertIn(
def test_cook_it_through_routes_single_session_work(self):
workflow = main_loop_workflow_text("single-session")
for required in (
"当前 `HEAD` 为 review fixed point",
"`<fixed-point>` 作为 `code-review` 的 fixed point",
"仅运行 Standards axis",
"`diagnosing-bugs` 完整执行 Phase 1-6",
"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",
)
):
self.assertIn(required, workflow)
self.assertNotIn("`<fixed-point>...HEAD`", workflow)
bug = section(workflow, "## 入口 3", "## 完成与升级")
self.assertNotIn("grill-with-docs", bug)
def test_agent_rules_commits_planning_baseline_before_claim(self):
rules = rules_text()
main_chain = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
def test_cook_it_through_routes_feature_planning_and_onramps(self):
planning = main_loop_workflow_text("feature-planning")
for required in (
"`wayfinder`",
"`to-spec -> to-tickets`",
"`research`",
"先进入 `grill-with-docs`",
"首次运行 `setup-matt-pocock-skills`",
"seam confirmation 在 `to-spec` 与 `tdd`",
"`tdd` 不得在未经确认的 seam 上开始",
):
self.assertIn(required, planning)
self.assertNotIn("`prototype`", planning)
def test_cook_it_through_commits_planning_baseline_before_claim(self):
planning = main_loop_workflow_text("feature-planning")
ordered_steps = (
"-> to-spec",
"-> to-tickets",
@@ -329,58 +532,28 @@ class TemplateContractsTests(unittest.TestCase):
"-> 提交 planning baseline",
"-> main_loop.py claim",
)
positions = [main_chain.index(step) for step in ordered_steps]
positions = [planning.index(step) for step in ordered_steps]
self.assertEqual(positions, sorted(positions))
for durable_input in (
"`.scratch/<feature>/spec.md`",
"`.scratch/<feature>/issues/*.md`",
"`.scratch/queue.md`",
):
self.assertIn(durable_input, main_chain)
self.assertIn("任何 `claim` 之", main_chain)
self.assertIn("不隐式提交", main_chain)
self.assertIn(durable_input, planning)
self.assertIn("任何 claim ", planning)
self.assertIn("不隐式提交", planning)
def test_agent_rules_commits_final_workflow_state_after_integration(self):
rules = rules_text()
main_chain = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
integration = section(rules, "### Feature 顺序集成", "## Git 与证据门禁")
def test_cook_it_through_defines_unattended_fallback(self):
planning = main_loop_workflow_text("feature-planning")
execution = main_loop_workflow_text("ticket-execution")
self.assertIn("尚未 claim ticket 时", planning)
self.assertIn("to-questionnaire", planning)
self.assertIn(".scratch/questions/<slug>.md", planning)
self.assertIn("从 `grill-with-docs` 恢复", normalized_prose(planning))
self.assertIn("finish --result blocked", execution)
self.assertLess(
main_chain.index("-> main_loop.py integrate"),
main_chain.index("-> 提交 final workflow state"),
)
for durable_path in (
"`.scratch/<feature>/`",
"`.scratch/queue.md`",
"`.scratch/<feature>/.main-loop.json`",
):
self.assertIn(durable_path, integration)
self.assertIn("不得用\n`git add .scratch`", integration)
self.assertIn("不得 amend 或 squash", integration)
self.assertIn("`MAIN_INTEGRATION_COMMIT`", integration)
def test_agent_rules_defines_a_local_ticket_execution_adapter(self):
rules = rules_text()
main_flow = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
adapter = section(rules, "## 本地 Ticket 执行协议", "## 文档职责")
self.assertIn("本地 ticket 执行协议", main_flow)
ordered_steps = (
"读取已领取 ticket 的 spec",
"按 `tdd`",
"提交全部实现",
"运行 `code-review`",
"调用 `main_loop.py finish`",
)
positions = [adapter.index(step) for step in ordered_steps]
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.
def test_cook_it_through_binds_state_and_evidence_to_claim(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
for key in (
"FEATURE",
"TICKET",
@@ -391,128 +564,103 @@ class TemplateContractsTests(unittest.TestCase):
"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",
self.assertIn(f"`{key}`", execution)
self.assertIn('--state-root "<PROJECT_ROOT>/.scratch"', execution)
self.assertIn('--repo-root "<PROJECT_ROOT>"', execution)
self.assertNotIn("--repo-root .", execution)
self.assertIn("把该 `FEATURE_HEAD` 合入 ticket branch", execution)
def test_cook_it_through_defines_ticket_execution_adapter(self):
execution = main_loop_workflow_text("ticket-execution")
ordered_steps = (
"读取已领取 ticket 的 spec",
"按 `tdd`",
"提交全部实现",
"运行 `code-review` 的 Standards/Spec",
"结构化证据调用 main_loop.py finish",
)
# 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):
rules = rules_text()
review = " ".join(
section(rules, "### Review 适配契约", "### Ticket 完成或状态转换").split()
)
positions = [execution.index(step) for step in ordered_steps]
self.assertEqual(positions, sorted(positions))
for required in (
"fixed point",
"`.scratch/<feature>/spec.md`",
"`.scratch/<feature>/issues/<ticket>-*.md`",
"`<STATE_ROOT>/<feature>/spec.md`",
"`<STATE_ROOT>/<feature>/issues/<NN>-*.md`",
"零个未解决的硬 finding",
"不得据此填写 `standards=pass` 或 `spec=pass`",
):
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",
"不得填写 pass",
):
self.assertIn(required, execution)
def test_cook_it_through_defines_lease_and_stuck_ticket_recovery(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
for required in (
"固定为 30 分钟",
"每 10 分钟",
"`reclaim` 只接管 stale 的 `claimed`",
"release-ticket",
"claim 环境准备失败",
"会占住该 ticket",
"原 `BASE`",
"blocked/skipped 必须给 reason",
):
self.assertIn(required, execution)
def test_cook_it_through_stops_on_integration_visibility_retry(self):
execution = main_loop_workflow_text("ticket-execution")
for key in (
"TICKET",
"DEPENDENCY",
"INTEGRATION_COMMIT",
"WORKSPACE",
"BRANCH",
"BRANCH_HEAD",
"TICKET_BRANCH",
"TICKET_BRANCH_HEAD",
"SYNC_BRANCH",
"MAIN_BRANCH",
"MAIN_HEAD",
"SYNC_COMMAND",
):
self.assertIn(f"`{key}`", execution)
self.assertIn("任一字段缺失", execution)
self.assertIn("取得正式 assignment 前不得继续", execution)
def test_cook_it_through_requires_fresh_evidence_artifacts(self):
bundle = main_loop_bundle_text()
execution = main_loop_workflow_text("ticket-execution")
integration = main_loop_workflow_text("feature-integration")
for required in (
"fresh UTF-8 JSON artifact",
"finish --help",
".scratch/<feature>/evidence/",
"无法证明命令真的执行过",
):
self.assertIn(required, execution)
for required in ("三个独立门禁,不能互相替代", "integrate --help"):
self.assertIn(required, integration)
self.assertIn("禁止伪造或复用证据 artifact", bundle)
for required in ("output_sha256", "report_sha256"):
self.assertIn(required, MAIN_LOOP.EVIDENCE_HELP)
self.assertIn("--main-verified", required_flags(subcommand_parsers()["integrate"]))
def test_cook_it_through_commits_final_state_after_integration(self):
integration = main_loop_workflow_text("feature-integration")
self.assertLess(
integration.index("main_loop.py integrate"),
integration.index("提交 final workflow state"),
)
for durable_path in (
"`.scratch/<feature>/`",
"`.scratch/queue.md`",
"`.main-loop.json`",
):
self.assertIn(durable_path, integration)
self.assertIn("不要运行 `git add .scratch`", integration)
self.assertIn("不得 amend/squash", integration)
self.assertIn("`MAIN_INTEGRATION_COMMIT`", integration)
def test_agent_rules_reads_claimed_context_after_claim_and_defines_lease_policy(self):
rules = rules_text()
startup = section(rules, "## 会话启动", "## 任务入口")
claim = section(rules, "### 领取", "### 心跳和接管")
lease = section(rules, "### 心跳和接管", "### Review 适配契约")
self.assertNotIn("当前 `.scratch/<feature>/spec.md`", startup)
self.assertNotIn("当前 `.scratch/<feature>/issues/<ticket>.md`", startup)
self.assertIn("领取成功后立即读取", claim)
self.assertIn("全局唯一", claim)
self.assertIn("每 10 分钟", lease)
self.assertIn("固定为 30 分钟", lease)
def test_agent_rules_orders_task_entries_by_cost_with_upgrade_conditions(self):
rules = rules_text()
entries = section(rules, "## 任务入口", "## 正式工程主链")
entry_headings = [h for h in headings(entries) if h.startswith("入口 ")]
self.assertEqual(
entry_headings,
[
"入口 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")
def test_cook_it_through_orders_phase_boundary_options_and_reload(self):
boundary = normalized_prose(main_loop_session_boundary_text())
ordered_options = (
"继续当前 session",
"使用 `clear`",
@@ -520,64 +668,57 @@ class TemplateContractsTests(unittest.TestCase):
"交给 subagent",
"使用 `compact`",
)
positions = [phase_boundaries.index(option) for option in ordered_options]
positions = [boundary.index(option) for option in ordered_options]
self.assertEqual(positions, sorted(positions))
self.assertIn("150k", phase_boundaries)
self.assertIn("`handoff` 解决的是可移植性", phase_boundaries)
def test_agent_rules_documents_every_main_loop_subcommand_and_required_flag(self):
rules = rules_text()
parsers = subcommand_parsers()
for command, parser in parsers.items():
self.assertIn(
f"main_loop.py {command}",
rules,
msg=f"undocumented subcommand: {command}",
)
for flag in required_flags(parser):
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",
)
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/",
"无法证明命令真的执行过",
"下一阶段需要当前 session 作为 primary source",
"约 150k tokens",
"`handoff` 解决的是可移植性",
"重新加载 `SKILL.md` 与当前路由文件",
"不得只依据 `status` 输出继续",
"`domain-modeling`",
"`codebase-design` 只作词汇来源",
):
self.assertIn(required, gate)
self.assertIn(required, boundary)
def test_agent_rules_only_references_installed_skills(self):
rules = rules_text()
def test_cook_it_through_delegates_command_semantics_to_help(self):
skill = main_loop_skill_text()
bundle = main_loop_bundle_text()
parsers = subcommand_parsers()
self.assertIn("main_loop.py <command> --help", skill)
self.assertIsNone(
re.search(r"\|\s*`?main_loop\.py (?:enqueue|status|claim|finish)", bundle),
msg="command responsibility tables duplicate argparse help",
)
for command, parser in parsers.items():
self.assertIn(f"main_loop.py {command}", bundle)
self.assertTrue(parser.description, msg=f"thin help for {command}")
for flag in required_flags(parser):
action = option_action(parser, flag)
self.assertNotIn(action.help, (None, argparse.SUPPRESS))
documented = set(re.findall(r"main_loop\.py ([a-z][a-z-]*)", bundle))
self.assertEqual(documented - set(parsers), set())
def test_cook_it_through_documents_executable_state_transitions(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
finish = subcommand_parsers()["finish"]
result = option_action(finish, "--result")
self.assertEqual(
set(result.choices or ()),
{"resolved", "blocked", "released", "skipped"},
)
self.assertIn("blocked/skipped 必须给 reason", execution)
release_ticket = subcommand_parsers()["release-ticket"]
release_options = {
option
for action in release_ticket._actions # noqa: SLF001
for option in action.option_strings
}
self.assertNotIn("--repo-root", release_options)
self.assertNotIn("--owner", release_options)
def test_workflow_instructions_only_reference_installed_skills(self):
instructions = "\n".join((rules_text(), main_loop_bundle_text()))
skills = installed_skills()
machine_vocabulary = (
set(subcommand_parsers())
@@ -587,54 +728,17 @@ class TemplateContractsTests(unittest.TestCase):
)
referenced = {
token
for token in re.findall(r"`([a-z][a-z0-9-]+)`", rules)
for token in re.findall(r"`([a-z][a-z0-9-]+)`", instructions)
if token not in machine_vocabulary
}
self.assertTrue(referenced)
self.assertEqual(sorted(referenced - skills), [])
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):
rules = (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
isolation = rules.split("## 执行隔离", 1)[1].split(
"## 主循环命令", 1
)[0]
normalized = " ".join(isolation.split())
self.assertIn("`main_loop.py` 只支持 local Markdown tracker", normalized)
self.assertIn("没有 远程 tracker adapter", normalized)
self.assertIn("跨机器或独立 clone 的并发不受支持", normalized)
self.assertNotIn("必须改用具备远程", normalized)
def test_cook_it_through_limits_state_to_shared_local_markdown(self):
skill = normalized_prose(main_loop_skill_text())
self.assertIn("local Markdown tracker", skill)
self.assertIn("远程 tracker、独立 clone 或跨机器状态", skill)
self.assertNotIn("必须改用具备远程", skill)
if __name__ == "__main__":