♻️ refactor(cook-it-through): move workflow engine into skill
This commit is contained in:
@@ -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 # 统一入口配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
- 禁止伪造或复用证据 artifact;integration 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 @@
|
||||
# 入口 4:feature 规划与入队
|
||||
|
||||
## 选择与建立上下文
|
||||
|
||||
巨大而模糊的工作先用 `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 全部 tickets;queue 顺序只连接
|
||||
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 必须给 reason,released 回到 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。
|
||||
@@ -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 来自已领取 ticket,Phase 6 后直接继续本地 ticket 执行协议的提交、review 和 finish
|
||||
门禁。
|
||||
|
||||
### 入口 4:新 feature 或设计变更
|
||||
|
||||
以下任一条成立时进入完整工程主链:
|
||||
|
||||
- 存在需要用户决策的产品取舍或架构方向
|
||||
- 需要新的测试 seam,或新增公开接口、配置项、数据格式
|
||||
- 跨两个以上模块边界
|
||||
- 涉及数据迁移、回滚或兼容性窗口
|
||||
- 需要跨 session 调度或多 session 并发
|
||||
- 预计无法在一个 session 内交付
|
||||
|
||||
**边界不清时先按入口 2 起步**,触到入口 2 的任一升级条件时立即转入本入口。"不确定"的正确
|
||||
处置是用最小路径试探到边界,不是预付最贵的流程。
|
||||
|
||||
### 非交互模式下的入口 4
|
||||
|
||||
主链的 grilling 收敛和 seam confirmation 需要用户在场。无人值守而判定为入口 4 时:已有已领取
|
||||
ticket 就 `finish --result blocked --reason "<待确认的具体决策>"`;尚无 ticket 则不得代替用户
|
||||
决策、也不得降级到入口 2 硬做。用 `to-questionnaire` 把待决问题写到项目根目录的
|
||||
`.scratch/questions/<slug>.md`,记录阻塞阶段、已知上下文、待决问题和恢复入口;用户回答后从
|
||||
`grill-with-docs` 恢复。不得把问题清单写入临时 worktree 或稳定知识文件。
|
||||
|
||||
## 正式工程主链
|
||||
|
||||
入口 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 point:ticket review 用 claim 的 `BASE` 或重试返回的 `FEATURE_HEAD`;feature
|
||||
review 用最新 `main` HEAD
|
||||
- Spec sources:ticket review 用 `.scratch/<feature>/spec.md` 与
|
||||
`.scratch/<feature>/issues/<ticket>-*.md`;feature review 用该 spec 加本 feature 全部
|
||||
ticket 的 acceptance criteria。两者都从绝对 `STATE_ROOT` 解析
|
||||
|
||||
只有对应 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
@@ -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 baseline(spec + 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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user