From 9b95bf2682a9d6f2eed05c5598c47fc2139ac0a1 Mon Sep 17 00:00:00 2001 From: csh Date: Thu, 20 Aug 2026 17:02:34 +0800 Subject: [PATCH] :recycle: refactor(cook-it-through): move workflow engine into skill --- README.md | 14 +- SKILLS.md | 15 + playbook.example.toml | 3 +- scripts/playbook.py | 86 +- skills/README.md | 7 +- skills/cook-it-through/SKILL.md | 73 + .../cook-it-through/rules/session-boundary.md | 29 + .../cook-it-through/scripts}/main_loop.py | 1163 +++++++++----- .../scripts/main_loop_scheduler.py | 387 +++++ .../workflows/feature-integration.md | 26 + .../workflows/feature-planning.md | 55 + .../workflows/single-session.md | 27 + .../workflows/ticket-execution.md | 78 + templates/AGENT_RULES.template.md | 521 +------ templates/README.md | 78 +- test/test_main_loop_cli.py | 1377 ++++++++++++++--- test/test_main_loop_scheduler.py | 425 +++++ test/test_playbook.py | 239 ++- test/test_template_contracts.py | 776 ++++++---- 19 files changed, 3905 insertions(+), 1474 deletions(-) create mode 100644 skills/cook-it-through/SKILL.md create mode 100644 skills/cook-it-through/rules/session-boundary.md rename {scripts => skills/cook-it-through/scripts}/main_loop.py (64%) create mode 100644 skills/cook-it-through/scripts/main_loop_scheduler.py create mode 100644 skills/cook-it-through/workflows/feature-integration.md create mode 100644 skills/cook-it-through/workflows/feature-planning.md create mode 100644 skills/cook-it-through/workflows/single-session.md create mode 100644 skills/cook-it-through/workflows/ticket-execution.md create mode 100644 test/test_main_loop_scheduler.py diff --git a/README.md b/README.md index 927da621..fcfad9ab 100644 --- a/README.md +++ b/README.md @@ -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**:始终按区块更新(``) - **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 # 统一入口配置 diff --git a/SKILLS.md b/SKILLS.md index 4daa0178..8d3fcdc5 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -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.`,再复制新版本,便于手动回退。若不需要备份: ```toml diff --git a/playbook.example.toml b/playbook.example.toml index 6262f30e..fad05113 100644 --- a/playbook.example.toml +++ b/playbook.example.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 后重装 diff --git a/scripts/playbook.py b/scripts/playbook.py index e10b9ae6..436ecaf8 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -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: "", 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 diff --git a/skills/README.md b/skills/README.md index 059f8447..018cd0e8 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 diff --git a/skills/cook-it-through/SKILL.md b/skills/cook-it-through/SKILL.md new file mode 100644 index 00000000..30565e64 --- /dev/null +++ b/skills/cook-it-through/SKILL.md @@ -0,0 +1,73 @@ +--- +name: cook-it-through +description: "当用户要在一个目标项目里推进实质性工程任务、需要先确定执行深度和验证强度时使用;也用于该项目 .scratch 本地 ticket 队列的规划、领取、交付与集成。触发说法包括「按流程做这个」「这个需求怎么落地」「继续这个 ticket」「集成这个 feature」「.scratch 里还有什么可做」「谁在跑这个队列」。不用于纯 TSL 语法/API 查询、只写 commit message、诊断远端 Gitea CI。" +--- + +# Cook It Through + +## 先确定项目与边界 + +- `` 是本 skill 的绝对目录,只用于定位随 skill 安装的执行引擎; + 它不是目标项目、状态目录或工作区。 +- `` 只能来自用户或任务明确指定的 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 或跨机器状态。 +- 只从 `/scripts/main_loop.py` 调用机器入口。构造任何命令前先运行 + `python /scripts/main_loop.py --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=@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, +并报告完成、未完成、证据与风险。 diff --git a/skills/cook-it-through/rules/session-boundary.md b/skills/cook-it-through/rules/session-boundary.md new file mode 100644 index 00000000..e5705715 --- /dev/null +++ b/skills/cook-it-through/rules/session-boundary.md @@ -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` 只作词汇来源,不单独起会话。 diff --git a/scripts/main_loop.py b/skills/cook-it-through/scripts/main_loop.py similarity index 64% rename from scripts/main_loop.py rename to skills/cook-it-through/scripts/main_loop.py index b5712258..b6a822e8 100644 --- a/scripts/main_loop.py +++ b/skills/cook-it-through/scripts/main_loop.py @@ -7,6 +7,7 @@ import hashlib import json import os import re +import shlex import subprocess import sys import tempfile @@ -15,6 +16,23 @@ import time from pathlib import Path from typing import Any, Iterator, Optional + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from main_loop_scheduler import ( # noqa: E402 + Dependency, + FeatureId, + FeatureIntegrationId, + FeatureRecord, + Scheduler, + SchedulerError, + TicketId, + TicketRecord, + parse_dependencies, +) + try: import fcntl except ImportError: # pragma: no cover @@ -37,20 +55,16 @@ TITLE_RE = re.compile( r"^#\s+(?P\d{2,})\s+[-\N{EN DASH}\N{EM DASH}]\s+(?P\S.*)$", re.MULTILINE, ) -STATUS_RE = re.compile(r"^\*\*Status:\*\*\s*(?P<status>\S+)\s*$", re.MULTILINE) +STATUS_RE = re.compile( + r"^\*\*Status:\*\*[ \t]*(?P<status>[^\s\r\n]+)[ \t]*$", + re.MULTILINE, +) BLOCKED_BY_RE = re.compile( - r"^\*\*Blocked by:\*\*\s*(?P<blockers>.+?)\s*$", re.MULTILINE + r"^\*\*Blocked by:\*\*[ \t]*(?P<blockers>[^\r\n]*?)[ \t]*$", + re.MULTILINE, ) ACCEPTANCE_RE = re.compile(r"^-\s+\[[ xX]\]\s+\S", re.MULTILINE) -NO_BLOCKERS_RE = re.compile( - r"^none(?:\s*[-\N{EN DASH}\N{EM DASH}:]\s*\S.*)?$", re.IGNORECASE -) -BLOCKER_SEPARATOR_RE = re.compile(r";|,(?=\s*\d{2,}\b)") -BLOCKER_ENTRY_RE = re.compile( - r"^\s*(?P<number>\d{2,})(?:\s*[-\N{EN DASH}\N{EM DASH}:]\s*\S.*)?\s*$" -) QUEUE_ENTRY_RE = re.compile(r"^-\s+`(?P<slug>[a-z0-9][a-z0-9-]*)`\s*$") -FEATURE_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") ALLOWED_STATUSES = { "ready-for-agent", "claimed", @@ -62,6 +76,7 @@ SATISFIED_STATUSES = {"resolved", "skipped"} THREAD_LOCKS: dict[str, threading.Lock] = {} THREAD_LOCKS_GUARD = threading.Lock() SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +GIT_OBJECT_ID_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") class StateError(ValueError): @@ -70,49 +85,33 @@ class StateError(ValueError): @dataclass(frozen=True) class Ticket: - number: str + id: TicketId slug: str title: str - blockers: tuple[str, ...] + dependencies: tuple[Dependency, ...] status: str path: Path metadata: dict[str, Any] + @property + def number(self) -> str: + return self.id.number + @dataclass(frozen=True) class Feature: - slug: str + id: FeatureId path: Path - tickets: dict[str, Ticket] + tickets: dict[TicketId, Ticket] @property - def frontier(self) -> list[Ticket]: - return [ - ticket - for ticket in self.tickets.values() - if ticket.status == "ready-for-agent" - and all( - self.tickets[blocker].status in SATISFIED_STATUSES - for blocker in ticket.blockers - ) - ] + def slug(self) -> str: + return str(self.id) @property def partial(self) -> bool: return any(ticket.status == "skipped" for ticket in self.tickets.values()) - @property - def state(self) -> str: - statuses = {ticket.status for ticket in self.tickets.values()} - if statuses <= SATISFIED_STATUSES: - return "ready-to-integrate" - if "claimed" in statuses or statuses & SATISFIED_STATUSES: - return "active" - if not self.frontier: - return "blocked" - return "queued" - - @dataclass(frozen=True) class EvidenceArtifact: data: dict[str, Any] @@ -173,36 +172,30 @@ def atomic_write_text(path: Path, text: str) -> None: temp_path.unlink() -def parse_blockers(raw: str, ticket_path: Path) -> tuple[str, ...]: - value = raw.strip() - if NO_BLOCKERS_RE.fullmatch(value): - return () - blockers: list[str] = [] - for entry in BLOCKER_SEPARATOR_RE.split(value): - match = BLOCKER_ENTRY_RE.fullmatch(entry) - if not match: - raise StateError(f"{ticket_path.name}: malformed Blocked by field") - blockers.append(match.group("number")) - if len(blockers) != len(set(blockers)): - raise StateError(f"{ticket_path.name}: duplicate blocker") - return tuple(blockers) +def parse_blockers(raw: str, ticket_id: TicketId) -> tuple[Dependency, ...]: + return parse_dependencies(raw, ticket_id) -def parse_ticket_metadata(text: str, ticket_path: Path) -> dict[str, Any]: +def parse_ticket_metadata( + text: str, + ticket_path: Path, + ticket_id: Optional[TicketId] = None, +) -> dict[str, Any]: """Read the scheduler-owned JSON block, if present. The block is deliberately ordinary Markdown so a ticket remains useful when opened without the scheduler. Malformed machine state is rejected instead of silently losing ownership information. """ + label = str(ticket_id) if ticket_id is not None else ticket_path.name start_count = text.count(TICKET_STATE_START) end_count = text.count(TICKET_STATE_END) if start_count == 0 and end_count == 0: return {} if start_count > 1 or end_count > 1: - raise StateError(f"{ticket_path.name}: multiple ticket state blocks") + raise StateError(f"{label}: multiple ticket state blocks") if start_count != 1 or end_count != 1: - raise StateError(f"{ticket_path.name}: malformed ticket state markers") + raise StateError(f"{label}: malformed ticket state markers") marker = re.compile( rf"{re.escape(TICKET_STATE_START)}\s*\n(?P<body>.*?)\n" @@ -211,16 +204,16 @@ def parse_ticket_metadata(text: str, ticket_path: Path) -> dict[str, Any]: ) match = marker.search(text) if not match: - raise StateError(f"{ticket_path.name}: malformed ticket state") + raise StateError(f"{label}: malformed ticket state") body = match.group("body").strip() if not body: return {} try: value = json.loads(body) except json.JSONDecodeError as exc: - raise StateError(f"{ticket_path.name}: malformed ticket state") from exc + raise StateError(f"{label}: malformed ticket state") from exc if not isinstance(value, dict): - raise StateError(f"{ticket_path.name}: ticket state must be an object") + raise StateError(f"{label}: ticket state must be an object") return value @@ -254,7 +247,7 @@ def update_ticket_state( if status is not None: status_pattern = re.compile(r"(?m)^\*\*Status:\*\*\s*[^\r\n]*$") if not status_pattern.search(text): - raise StateError(f"{ticket.path.name}: missing Status") + raise StateError(f"{ticket.id}: missing Status") text = status_pattern.sub(f"**Status:** {status}", text, count=1) if metadata is not None: text = render_ticket_state(text, metadata) @@ -286,7 +279,7 @@ def format_timestamp(value: datetime) -> str: def claim_is_stale(ticket: Ticket, now: datetime) -> bool: heartbeat = ticket.metadata.get("heartbeat_at") if not heartbeat: - raise StateError(f"{ticket.path.name}: claimed ticket has no heartbeat") + raise StateError(f"{ticket.id}: claimed ticket has no heartbeat") return ( now - parse_timestamp(str(heartbeat)) ).total_seconds() > CLAIM_STALE_AFTER_SECONDS @@ -370,9 +363,13 @@ def load_feature_state(feature: Feature) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: - raise StateError(f"{path.name}: malformed feature state") from exc + raise StateError( + f"{FeatureIntegrationId(feature.id)}: malformed feature state" + ) from exc if not isinstance(value, dict): - raise StateError(f"{path.name}: feature state must be an object") + raise StateError( + f"{FeatureIntegrationId(feature.id)}: feature state must be an object" + ) return value @@ -383,86 +380,75 @@ def write_feature_state(feature: Feature, state: dict[str, Any]) -> None: ) -def parse_ticket(path: Path) -> Ticket: +def parse_ticket(path: Path, feature_id: FeatureId) -> Ticket: file_match = TICKET_FILE_RE.fullmatch(path.name) if not file_match: - raise StateError(f"invalid ticket filename: {path.name}") + raise StateError( + f"{feature_id}: invalid ticket filename: {path.name}" + ) + ticket_id = TicketId(feature_id, file_match.group("number")) text = path.read_text(encoding="utf-8") title_match = TITLE_RE.search(text) if not title_match: - raise StateError(f"{path.name}: invalid title") + raise StateError(f"{ticket_id}: invalid title") number = file_match.group("number") if title_match.group("number") != number: - raise StateError(f"{path.name}: title number does not match filename") - status_match = STATUS_RE.search(text) - if not status_match: - raise StateError(f"{path.name}: missing Status") + raise StateError(f"{ticket_id}: title number does not match filename") + status_matches = tuple(STATUS_RE.finditer(text)) + if not status_matches: + raise StateError(f"{ticket_id}: missing Status") + if len(status_matches) != 1: + raise StateError(f"{ticket_id}: multiple Status fields") + status_match = status_matches[0] status = status_match.group("status") if status not in ALLOWED_STATUSES: - raise StateError(f"{path.name}: invalid status {status}") - blocked_by_match = BLOCKED_BY_RE.search(text) - if not blocked_by_match: - raise StateError(f"{path.name}: missing Blocked by") + raise StateError(f"{ticket_id}: invalid status {status}") + blocked_by_matches = tuple(BLOCKED_BY_RE.finditer(text)) + if not blocked_by_matches: + raise StateError(f"{ticket_id}: missing Blocked by") + if len(blocked_by_matches) != 1: + raise StateError(f"{ticket_id}: multiple Blocked by fields") + blocked_by_match = blocked_by_matches[0] if not ACCEPTANCE_RE.search(text): - raise StateError(f"{path.name}: missing acceptance criterion") - metadata = parse_ticket_metadata(text, path) + raise StateError(f"{ticket_id}: missing acceptance criterion") + metadata = parse_ticket_metadata(text, path, ticket_id) return Ticket( - number=number, + id=ticket_id, slug=file_match.group("slug"), title=title_match.group("title").strip(), - blockers=parse_blockers(blocked_by_match.group("blockers"), path), + dependencies=parse_blockers( + blocked_by_match.group("blockers"), + ticket_id, + ), status=status, path=path, metadata=metadata, ) -def validate_acyclic(tickets: dict[str, Ticket]) -> None: - visiting: set[str] = set() - visited: set[str] = set() - - def visit(number: str) -> None: - if number in visiting: - raise StateError(f"ticket dependency cycle includes {number}") - if number in visited: - return - visiting.add(number) - for blocker in tickets[number].blockers: - visit(blocker) - visiting.remove(number) - visited.add(number) - - for number in tickets: - visit(number) - - -def load_feature(state_root: Path, slug: str) -> Feature: - if not FEATURE_SLUG_RE.fullmatch(slug): - raise StateError(f"invalid feature slug: {slug}") - feature_path = state_root / slug +def load_feature(state_root: Path, feature_id: FeatureId) -> Feature: + feature_path = state_root / str(feature_id) if not (feature_path / "spec.md").is_file(): - raise StateError(f"{slug}: spec.md not found") + raise StateError(f"{feature_id}: spec.md not found") issues_path = feature_path / "issues" if not issues_path.is_dir(): - raise StateError(f"{slug}: issues directory not found") - ticket_paths = sorted(path for path in issues_path.iterdir() if path.is_file()) + raise StateError(f"{feature_id}: issues directory not found") + ticket_paths = sorted( + (path for path in issues_path.iterdir() if path.is_file()), + key=lambda path: path.name, + ) if not ticket_paths: - raise StateError(f"{slug}: no ticket files found") - tickets: dict[str, Ticket] = {} + raise StateError(f"{feature_id}: no ticket files found") + tickets: dict[TicketId, Ticket] = {} for path in ticket_paths: - ticket = parse_ticket(path) - if ticket.number in tickets: - raise StateError(f"{slug}: duplicate ticket number {ticket.number}") - tickets[ticket.number] = ticket - for ticket in tickets.values(): - for blocker in ticket.blockers: - if blocker not in tickets: - raise StateError(f"{ticket.path.name}: unknown blocker {blocker}") - validate_acyclic(tickets) - return Feature(slug=slug, path=feature_path, tickets=tickets) + ticket = parse_ticket(path, feature_id) + if ticket.id in tickets: + raise StateError(f"{feature_id}: duplicate ticket identity {ticket.id}") + tickets[ticket.id] = ticket + return Feature(id=feature_id, path=feature_path, tickets=tickets) -def load_queue(queue_path: Path) -> list[str]: +def load_queue(queue_path: Path) -> list[FeatureId]: if not queue_path.exists(): return [] lines = queue_path.read_text(encoding="utf-8").splitlines() @@ -473,37 +459,137 @@ def load_queue(queue_path: Path) -> list[str]: end = lines.index(QUEUE_END, start + 1) except ValueError as exc: raise StateError("queue.md has an invalid managed block") from exc - slugs: list[str] = [] + feature_ids: list[FeatureId] = [] for line in lines[start + 1 : end]: if not line.strip(): continue match = QUEUE_ENTRY_RE.fullmatch(line.strip()) if not match: raise StateError(f"queue.md has an invalid entry: {line.strip()}") - slug = match.group("slug") - if slug in slugs: - raise StateError(f"queue.md has a duplicate feature: {slug}") - slugs.append(slug) - return slugs + feature_id = FeatureId.parse(match.group("slug")) + if feature_id in feature_ids: + raise StateError(f"queue.md has a duplicate feature: {feature_id}") + feature_ids.append(feature_id) + return feature_ids -def render_queue(slugs: list[str]) -> str: - entries = [f"- `{slug}`" for slug in slugs] +def render_queue(feature_ids: list[FeatureId]) -> str: + entries = [f"- `{feature_id}`" for feature_id in feature_ids] return "\n".join( ["# Feature Queue", "", QUEUE_START, "", *entries, "", QUEUE_END, ""] ) -def enqueue_feature(state_root: Path, slug: str) -> str: +@dataclass(frozen=True) +class QueueSnapshot: + feature_ids: tuple[FeatureId, ...] + features: dict[FeatureId, Feature] + feature_states: dict[FeatureId, dict[str, Any]] + scheduler: Scheduler + + def feature(self, feature_id: FeatureId) -> Feature: + feature = self.features.get(feature_id) + if feature is None: + raise StateError(f"feature is not queued: {feature_id}") + return feature + + def ticket(self, ticket_id: TicketId) -> Ticket: + feature = self.feature(ticket_id.feature) + ticket = feature.tickets.get(ticket_id) + if ticket is None: + raise StateError(f"ticket not found: {ticket_id}") + return ticket + + +def validated_integration_commit( + feature_id: FeatureId, + state: dict[str, Any], +) -> Optional[str]: + value = state.get("integration_commit") + if value is None: + return None + if ( + not isinstance(value, str) + or not GIT_OBJECT_ID_RE.fullmatch(value.strip()) + ): + raise StateError( + f"{FeatureIntegrationId(feature_id)}: invalid integration_commit" + ) + return value.strip() + + +def load_queue_snapshot( + state_root: Path, + feature_ids: Optional[list[FeatureId]] = None, +) -> QueueSnapshot: + ordered_ids = tuple( + feature_ids + if feature_ids is not None + else load_queue(state_root / "queue.md") + ) + features: dict[FeatureId, Feature] = {} + states: dict[FeatureId, dict[str, Any]] = {} + records: list[FeatureRecord] = [] + for feature_id in ordered_ids: + feature = load_feature(state_root, feature_id) + state = load_feature_state(feature) + features[feature_id] = feature + states[feature_id] = state + tickets = tuple( + TicketRecord( + id=ticket.id, + slug=ticket.slug, + status=ticket.status, + dependencies=ticket.dependencies, + ) + for ticket in sorted( + feature.tickets.values(), + key=lambda item: (int(item.number), item.number, item.slug), + ) + ) + records.append( + FeatureRecord( + id=feature_id, + tickets=tickets, + integrated=validated_integration_commit(feature_id, state) is not None, + integration_blocked=bool(state.get("integration_blocked_reason")), + ) + ) + return QueueSnapshot( + feature_ids=ordered_ids, + features=features, + feature_states=states, + scheduler=Scheduler(tuple(records)), + ) + + +def enqueue_features( + state_root: Path, + requested_ids: tuple[FeatureId, ...], +) -> str: + if len(requested_ids) != len(set(requested_ids)): + duplicate = next( + feature_id + for feature_id in requested_ids + if requested_ids.count(feature_id) > 1 + ) + raise StateError(f"duplicate enqueue feature: {duplicate}") queue_path = state_root / "queue.md" with locked_state(state_root): - load_feature(state_root, slug) - slugs = load_queue(queue_path) - if slug in slugs: - return f"EXISTS={slug}" - slugs.append(slug) - atomic_write_text(queue_path, render_queue(slugs)) - return f"ENQUEUED={slug}" + feature_ids = load_queue(queue_path) + existing = set(feature_ids) + candidate_ids = list(feature_ids) + output: list[str] = [] + for feature_id in requested_ids: + if feature_id in existing: + output.append(f"EXISTS={feature_id}") + continue + candidate_ids.append(feature_id) + output.append(f"ENQUEUED={feature_id}") + load_queue_snapshot(state_root, candidate_ids) + if candidate_ids != feature_ids: + atomic_write_text(queue_path, render_queue(candidate_ids)) + return "\n".join(output) def status_report( @@ -512,44 +598,68 @@ def status_report( ) -> str: observed_at = now or datetime.now(timezone.utc) with locked_state(state_root): - slugs = load_queue(state_root / "queue.md") - if not slugs: + snapshot = load_queue_snapshot(state_root) + if not snapshot.feature_ids: return "NO FEATURES" + scheduler = snapshot.scheduler + integration_frontier = scheduler.integration_frontier output: list[str] = [] - for slug in slugs: - feature = load_feature(state_root, slug) - feature_metadata = load_feature_state(feature) - feature_status = ( - "integrated" - if feature_metadata.get("integration_commit") - else ( - "blocked" - if feature_metadata.get("integration_blocked_reason") - else feature.state - ) - ) + if integration_frontier is None: + output.append("INTEGRATION_FRONTIER=- STATE=complete") + else: + frontier_state = snapshot.feature_states[integration_frontier.feature] + if frontier_state.get("integration_blocked_reason"): + integration_state = "blocked" + elif scheduler.integration_frontier_ready: + integration_state = "ready" + else: + integration_state = "waiting" + waiting_on = scheduler.unsatisfied_dependencies(integration_frontier) output.append( - f"FEATURE={slug} STATE={feature_status} " + f"INTEGRATION_FRONTIER={integration_frontier} " + f"STATE={integration_state} " + f"WAITING_ON={';'.join(map(str, waiting_on)) or '-'}" + ) + + global_frontier = set(scheduler.ticket_frontier) + for feature_id in snapshot.feature_ids: + feature = snapshot.feature(feature_id) + feature_metadata = snapshot.feature_states[feature_id] + feature_status = scheduler.feature_state(feature_id) + output.append( + f"FEATURE={feature_id} STATE={feature_status} " f"PARTIAL={'yes' if feature.partial else 'no'}" ) - frontier = "" if feature_status == "integrated" else ",".join( - ticket.number for ticket in feature.frontier + frontier = ",".join( + str(ticket.id) + for ticket in sorted( + feature.tickets.values(), + key=lambda item: (int(item.number), item.number, item.slug), + ) + if ticket.id in global_frontier ) - output.append(f"FRONTIER={frontier or '-'}") - if feature_metadata.get("integration_commit"): + output.append(f"TICKET_FRONTIER={frontier or '-'}") + integration_commit = validated_integration_commit( + feature_id, + feature_metadata, + ) + if integration_commit: output.append( - f"MAIN_INTEGRATION_COMMIT={feature_metadata['integration_commit']}" + f"MAIN_INTEGRATION_COMMIT={integration_commit}" ) elif feature_metadata.get("integration_blocked_reason"): output.append( - f"FEATURE_BLOCKED={slug} " + f"FEATURE_BLOCKED={feature_id} " f"REASON={feature_metadata['integration_blocked_reason']}" ) - for ticket in feature.tickets.values(): + for ticket in sorted( + feature.tickets.values(), + key=lambda item: (int(item.number), item.number, item.slug), + ): if ticket.status == "claimed": stale = claim_is_stale(ticket, observed_at) output.append( - f"CLAIM={slug}/{ticket.number} " + f"CLAIM={ticket.id} " f"OWNER={ticket.metadata.get('claimed_by')} " f"HEARTBEAT={ticket.metadata.get('heartbeat_at')} " f"STALE={'yes' if stale else 'no'} " @@ -558,21 +668,28 @@ def status_report( ) if ticket.metadata.get("last_error"): output.append( - f"TICKET_ERROR={slug}/{ticket.number} " + f"TICKET_ERROR={ticket.id} " f"REASON={ticket.metadata['last_error']}" ) elif ticket.status == "blocked": output.append( - f"BLOCKED={slug}/{ticket.number} " + f"BLOCKED={ticket.id} " f"REASON={ticket.metadata.get('blocked_reason', 'unspecified')}" ) + elif ticket.status == "ready-for-agent": + waiting_on = scheduler.unsatisfied_dependencies(ticket.id) + if waiting_on: + output.append( + f"BLOCKED_DEPENDENCY={ticket.id} " + f"WAITING_ON={';'.join(map(str, waiting_on))}" + ) return "\n".join(output) def render_claim_context(feature: Feature, ticket: Ticket, metadata: dict[str, Any]) -> str: values = [ ("FEATURE", feature.slug), - ("TICKET", ticket.number), + ("TICKET", str(ticket.id)), ("CONTROL_ROOT", str(metadata["control_root"])), ("STATE_ROOT", str(metadata["state_root"])), ("WORKSPACE", str(metadata["workspace"])), @@ -589,11 +706,160 @@ def active_claims(features: list[Feature]) -> list[tuple[Feature, Ticket]]: for ticket in feature.tickets.values(): if ticket.status == "claimed": if not ticket.metadata.get("claimed_by"): - raise StateError(f"{ticket.path.name}: claimed ticket has no owner") + raise StateError(f"{ticket.id}: claimed ticket has no owner") claims.append((feature, ticket)) return claims +def integration_visibility_retry( + snapshot: QueueSnapshot, + repo_root: Path, + feature: Feature, + ticket: Ticket, + main_branch: str, +) -> Optional[str]: + """Return an actionable retry when an integrated dependency is not visible. + + A feature branch that predates ``main`` must be synchronized explicitly. We + do not merge behind an agent's back because that would invalidate its claim + base and could overwrite uncommitted work. + """ + integration_dependencies = snapshot.scheduler.integration_dependencies(ticket.id) + if not integration_dependencies: + return None + if not branch_exists(repo_root, main_branch): + raise StateError(f"main branch not found: {main_branch}") + main_head = git_output(repo_root, "rev-parse", main_branch) + feature_state = snapshot.feature_states[feature.id] + feature_branch = str( + feature_state.get("feature_branch", f"feature/{feature.id}") + ) + feature_branch_exists = branch_exists(repo_root, feature_branch) + branch_head = ( + git_output(repo_root, "rev-parse", feature_branch) + if feature_branch_exists + else "<not-created>" + ) + expected_ticket_branch = f"ticket/{feature.slug}/{ticket.number}-{ticket.slug}" + ticket_branch = str( + ticket.metadata.get("ticket_branch", expected_ticket_branch) + ) + if ticket_branch != expected_ticket_branch: + raise StateError( + f"{ticket.id}: claim branch mismatch: expected " + f"{expected_ticket_branch}, got {ticket_branch}" + ) + ticket_branch_exists = branch_exists(repo_root, ticket_branch) + ticket_branch_head = ( + git_output(repo_root, "rev-parse", ticket_branch) + if ticket_branch_exists + else "<not-created>" + ) + branch_workspaces = worktree_branch_paths(repo_root) + if ticket_branch_exists: + configured_workspace = Path( + str( + ticket.metadata.get( + "workspace", + snapshot.features[feature.id].path.parent + / "worktrees" + / feature.slug + / f"{ticket.number}-{ticket.slug}", + ) + ) + ).resolve() + sync_branch = ticket_branch + occupied_workspace = branch_workspaces.get(ticket_branch) + else: + configured_workspace = Path( + str( + feature_state.get( + "integration_workspace", + snapshot.features[feature.id].path.parent + / "worktrees" + / "_integration" + / str(feature.id), + ) + ) + ).resolve() + sync_branch = feature_branch + occupied_workspace = branch_workspaces.get(feature_branch) + workspace = occupied_workspace or configured_workspace + if occupied_workspace is None: + prepare_workspace = " && ".join( + ( + shlex.join(("mkdir", "-p", str(workspace.parent))), + shlex.join( + ( + "git", + "-C", + str(repo_root.resolve()), + "worktree", + "add", + str(workspace), + sync_branch, + ) + ), + ) + ) + else: + prepare_workspace = "" + merge_main = shlex.join( + ("git", "-C", str(workspace), "merge", main_branch) + ) + sync_command = ( + f"{prepare_workspace} && {merge_main}" + if prepare_workspace + else merge_main + ) + for dependency in integration_dependencies: + dependency_state = snapshot.feature_states[dependency.feature] + integration_commit = validated_integration_commit( + dependency.feature, + dependency_state, + ) + if integration_commit is None: + # The graph should already have filtered this out; keep the error + # explicit if a caller supplies an inconsistent snapshot. + raise StateError(f"{dependency}: integration commit is missing") + integration_commit = normalize_commit( + repo_root, + integration_commit, + str(dependency), + ) + if ticket_branch_exists: + visible_head = ticket_branch_head + elif feature_branch_exists: + visible_head = branch_head + else: + visible_head = main_head + visible = is_ancestor( + repo_root, + integration_commit, + main_head, + ) and is_ancestor(repo_root, integration_commit, visible_head) + if visible: + continue + return "\n".join( + [ + "RETRY: dependency integration is not visible", + f"TICKET={ticket.id}", + f"DEPENDENCY={dependency}", + f"INTEGRATION_COMMIT={integration_commit}", + f"WORKSPACE={workspace}", + f"BRANCH={feature_branch}", + f"BRANCH_HEAD={branch_head}", + f"TICKET_BRANCH={ticket_branch}", + f"TICKET_BRANCH_HEAD={ticket_branch_head}", + f"SYNC_BRANCH={sync_branch}", + f"MAIN_BRANCH={main_branch}", + f"MAIN_HEAD={main_head}", + f"SYNC_COMMAND={sync_command}", + ] + ) + return None + + def checkout_branch(repo_root: Path, branch: str) -> None: occupied = worktree_branch_paths(repo_root).get(branch) if occupied is not None and os.path.normcase(str(occupied)) != os.path.normcase( @@ -783,31 +1049,12 @@ def resume_claim( if current_branch(workspace) != ticket_branch: raise StateError(f"claim workspace is not on {ticket_branch}: {workspace}") else: - raise StateError(f"{ticket.path.name}: invalid claim isolation") + raise StateError(f"{ticket.id}: invalid claim isolation") metadata["heartbeat_at"] = utc_now() update_ticket_state(ticket, metadata=metadata) return render_claim_context(feature, ticket, metadata) -def choose_ticket_for_claim(features: list[Feature]) -> tuple[Optional[Feature], Optional[Ticket], str]: - for feature in features: - state = load_feature_state(feature) - if state.get("integration_commit"): - continue - if state.get("integration_blocked_reason"): - continue - if feature.state == "ready-to-integrate": - return feature, None, "integration-required" - frontier = sorted(feature.frontier, key=lambda item: (int(item.number), item.slug)) - if frontier: - return feature, frontier[0], "claim" - if any(ticket.status == "claimed" for ticket in feature.tickets.values()): - return feature, None, "busy" - # A feature with no frontier and no active claim is genuinely blocked; - # later queued features may be developed, but still integrate in order. - return None, None, "none" - - def claim_ticket( state_root: Path, repo_root: Path, @@ -820,10 +1067,12 @@ def claim_ticket( state_root = state_root.resolve() repo_root = resolve_repo_root(repo_root) with locked_state(state_root): - slugs = load_queue(state_root / "queue.md") - if not slugs: + snapshot = load_queue_snapshot(state_root) + if not snapshot.feature_ids: return "NO FEATURES" - features = [load_feature(state_root, slug) for slug in slugs] + features = [ + snapshot.feature(feature_id) for feature_id in snapshot.feature_ids + ] claims = active_claims(features) for feature, ticket in claims: @@ -835,13 +1084,29 @@ def claim_ticket( if any(ticket.metadata.get("isolation") == "in-place" for _, ticket in claims): return "BUSY" - feature, ticket, disposition = choose_ticket_for_claim(features) - if disposition == "integration-required" and feature is not None: - return f"INTEGRATION_REQUIRED={feature.slug}" - if disposition == "busy": - return "BUSY" - if ticket is None or feature is None: + frontier = snapshot.scheduler.ticket_frontier + if not frontier: + integration_frontier = snapshot.scheduler.integration_frontier + if ( + integration_frontier is not None + and snapshot.scheduler.integration_frontier_ready + ): + return f"INTEGRATION_REQUIRED={integration_frontier}" + if claims: + return "BUSY" return "NOOP: no claimable tickets" + selected_id = frontier[0] + feature = snapshot.feature(selected_id.feature) + ticket = snapshot.ticket(selected_id) + visibility_retry = integration_visibility_retry( + snapshot, + repo_root, + feature, + ticket, + main_branch, + ) + if visibility_retry is not None: + return visibility_retry selected_isolation = isolation if isolation == "auto": @@ -1061,25 +1326,23 @@ def append_history( def heartbeat_ticket( state_root: Path, - feature_slug: str, - ticket_number: str, + ticket_id: TicketId, owner: str, at: datetime, ) -> str: - with locked_state(state_root.resolve()): - feature = load_feature(state_root.resolve(), feature_slug) - ticket = feature.tickets.get(ticket_number) - if ticket is None: - raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + state_root = state_root.resolve() + with locked_state(state_root): + snapshot = load_queue_snapshot(state_root) + ticket = snapshot.ticket(ticket_id) if ticket.status != "claimed": - raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + raise StateError(f"ticket {ticket_id} is not claimed") if ticket.metadata.get("claimed_by") != owner: - raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + raise StateError(f"ticket {ticket_id} is owned by another session") metadata = dict(ticket.metadata) timestamp = format_timestamp(at) metadata["heartbeat_at"] = timestamp update_ticket_state(ticket, metadata=metadata) - return f"HEARTBEAT={feature_slug}/{ticket_number}\nAT={timestamp}" + return f"HEARTBEAT={ticket_id}\nAT={timestamp}" def validate_reclaim_workspace(repo_root: Path, ticket: Ticket) -> None: @@ -1100,27 +1363,25 @@ def validate_reclaim_workspace(repo_root: Path, ticket: Ticket) -> None: def reclaim_ticket( state_root: Path, repo_root: Path, - feature_slug: str, - ticket_number: str, + ticket_id: TicketId, owner: str, at: datetime, ) -> str: state_root = state_root.resolve() repo_root = resolve_repo_root(repo_root) with locked_state(state_root): - feature = load_feature(state_root, feature_slug) - ticket = feature.tickets.get(ticket_number) - if ticket is None: - raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + snapshot = load_queue_snapshot(state_root) + feature = snapshot.feature(ticket_id.feature) + ticket = snapshot.ticket(ticket_id) if ticket.status != "claimed": - raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + raise StateError(f"ticket {ticket_id} is not claimed") previous_owner = str(ticket.metadata.get("claimed_by", "")) if not previous_owner: - raise StateError(f"ticket {feature_slug}/{ticket_number} has no owner") + raise StateError(f"ticket {ticket_id} has no owner") if previous_owner == owner: raise StateError("reclaim requires a different owner") if not claim_is_stale(ticket, at): - raise StateError(f"ticket {feature_slug}/{ticket_number} is not stale") + raise StateError(f"ticket {ticket_id} is not stale") validate_reclaim_workspace(repo_root, ticket) metadata = dict(ticket.metadata) @@ -1145,29 +1406,26 @@ def reclaim_ticket( def finish_nonresolved_ticket( state_root: Path, - feature_slug: str, - ticket_number: str, + ticket_id: TicketId, owner: str, result: str, reason: str, ) -> str: state_root = state_root.resolve() with locked_state(state_root): - feature = load_feature(state_root, feature_slug) - ticket = feature.tickets.get(ticket_number) - if ticket is None: - raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + snapshot = load_queue_snapshot(state_root) + ticket = snapshot.ticket(ticket_id) allowed_source = ticket.status == "claimed" or ( result == "released" and ticket.status == "blocked" ) if not allowed_source: raise StateError( - f"ticket {feature_slug}/{ticket_number} cannot become {result} " + f"ticket {ticket_id} cannot become {result} " f"from {ticket.status}" ) metadata = dict(ticket.metadata) if metadata.get("claimed_by") != owner: - raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + raise StateError(f"ticket {ticket_id} is owned by another session") if result in {"blocked", "skipped"} and not reason.strip(): raise StateError(f"{result} requires a reason") @@ -1195,13 +1453,12 @@ def finish_nonresolved_ticket( metadata.pop("claimed_at", None) metadata.pop("heartbeat_at", None) update_ticket_state(ticket, status=status, metadata=metadata) - return f"{result.upper()}={feature_slug}/{ticket_number}" + return f"{result.upper()}={ticket_id}" def release_blocked_ticket( state_root: Path, - feature_slug: str, - ticket_number: str, + ticket_id: TicketId, reason: str, ) -> str: """Return a blocked ticket to the queue without its original owner. @@ -1215,13 +1472,11 @@ def release_blocked_ticket( raise StateError("ticket release requires a reason") state_root = state_root.resolve() with locked_state(state_root): - feature = load_feature(state_root, feature_slug) - ticket = feature.tickets.get(ticket_number) - if ticket is None: - raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + snapshot = load_queue_snapshot(state_root) + ticket = snapshot.ticket(ticket_id) if ticket.status != "blocked": raise StateError( - f"ticket {feature_slug}/{ticket_number} is not blocked: {ticket.status}" + f"ticket {ticket_id} is not blocked: {ticket.status}" ) metadata = dict(ticket.metadata) previous_owner = str(metadata.get("claimed_by", "")) @@ -1246,14 +1501,13 @@ def release_blocked_ticket( ): metadata.pop(key, None) update_ticket_state(ticket, status="ready-for-agent", metadata=metadata) - return f"TICKET_RELEASED={feature_slug}/{ticket_number}" + return f"TICKET_RELEASED={ticket_id}" def finish_resolved_ticket( state_root: Path, repo_root: Path, - feature_slug: str, - ticket_number: str, + ticket_id: TicketId, owner: str, implementation_commit: str, feature_head: str, @@ -1275,20 +1529,19 @@ def finish_resolved_ticket( repo_root = resolve_repo_root(repo_root) with locked_state(state_root): - feature = load_feature(state_root, feature_slug) - ticket = feature.tickets.get(ticket_number) - if ticket is None: - raise StateError(f"{feature_slug}: ticket {ticket_number} not found") + snapshot = load_queue_snapshot(state_root) + feature = snapshot.feature(ticket_id.feature) + ticket = snapshot.ticket(ticket_id) metadata = dict(ticket.metadata) if ticket.status == "resolved" and metadata.get("integration_commit"): return ( - f"RESOLVED={feature_slug}/{ticket_number}\n" + f"RESOLVED={ticket_id}\n" f"INTEGRATION_COMMIT={metadata['integration_commit']}" ) if ticket.status != "claimed": - raise StateError(f"ticket {feature_slug}/{ticket_number} is not claimed") + raise StateError(f"ticket {ticket_id} is not claimed") if metadata.get("claimed_by") != owner: - raise StateError(f"ticket {feature_slug}/{ticket_number} is owned by another session") + raise StateError(f"ticket {ticket_id} is owned by another session") isolation = metadata.get("isolation") expected_workspace = Path(str(metadata.get("workspace", ""))).resolve() if isolation == "in-place": @@ -1306,14 +1559,16 @@ def finish_resolved_ticket( feature_state = load_feature_state(feature) integration_value = feature_state.get("integration_workspace") if not integration_value: - raise StateError(f"{feature_slug}: integration workspace is not recorded") + raise StateError( + f"{ticket_id.feature}: integration workspace is not recorded" + ) integration_workspace = Path(str(integration_value)).resolve() if not integration_workspace.is_dir(): raise StateError( f"integration workspace not found: {integration_workspace}" ) else: - raise StateError(f"{ticket.path.name}: invalid claim isolation") + raise StateError(f"{ticket.id}: invalid claim isolation") dirty_path = checkout_is_dirty(ticket_workspace) if dirty_path: @@ -1396,7 +1651,7 @@ def finish_resolved_ticket( "merge", "--no-ff", "-m", - f"Integrate {feature_slug}/{ticket_number}", + f"Integrate {ticket_id}", ticket_branch, ) if merge.returncode != 0: @@ -1415,12 +1670,12 @@ def finish_resolved_ticket( evidence = { "verification": snapshot_evidence( feature, - f"ticket-{ticket_number}-verification", + f"ticket-{ticket_id.number}-verification", verification_artifact, ), "review": snapshot_evidence( feature, - f"ticket-{ticket_number}-review", + f"ticket-{ticket_id.number}-review", review_artifact, ), } @@ -1436,7 +1691,7 @@ def finish_resolved_ticket( metadata.pop("last_error", None) update_ticket_state(ticket, status="resolved", metadata=metadata) return ( - f"RESOLVED={feature_slug}/{ticket_number}\n" + f"RESOLVED={ticket_id}\n" f"INTEGRATION_COMMIT={integration_commit}" ) @@ -1518,21 +1773,24 @@ def cleanup_integrated_feature_worktrees( return warnings -def block_feature_integration(state_root: Path, feature_slug: str, reason: str) -> str: +def block_feature_integration( + state_root: Path, + feature_id: FeatureId, + reason: str, +) -> str: if not reason.strip(): raise StateError("feature block requires a reason") state_root = state_root.resolve() with locked_state(state_root): - slugs = load_queue(state_root / "queue.md") - if feature_slug not in slugs: - raise StateError(f"feature is not queued: {feature_slug}") - feature = load_feature(state_root, feature_slug) - state = load_feature_state(feature) - if state.get("integration_commit"): - raise StateError(f"feature is already integrated: {feature_slug}") - if feature.state != "ready-to-integrate": + snapshot = load_queue_snapshot(state_root) + feature = snapshot.feature(feature_id) + state = snapshot.feature_states[feature_id] + if validated_integration_commit(feature_id, state): + raise StateError(f"feature is already integrated: {feature_id}") + feature_status = snapshot.scheduler.feature_state(feature_id) + if feature_status != "ready-to-integrate": raise StateError( - f"feature {feature_slug} is not ready to integrate: {feature.state}" + f"feature {feature_id} is not ready to integrate: {feature_status}" ) timestamp = utc_now() state["integration_blocked_reason"] = reason.strip() @@ -1544,31 +1802,29 @@ def block_feature_integration(state_root: Path, feature_slug: str, reason: str) reason=reason.strip(), ) write_feature_state(feature, state) - return f"FEATURE_BLOCKED={feature_slug}" + return f"FEATURE_BLOCKED={feature_id}" -def release_feature_integration(state_root: Path, feature_slug: str) -> str: +def release_feature_integration(state_root: Path, feature_id: FeatureId) -> str: state_root = state_root.resolve() with locked_state(state_root): - slugs = load_queue(state_root / "queue.md") - if feature_slug not in slugs: - raise StateError(f"feature is not queued: {feature_slug}") - feature = load_feature(state_root, feature_slug) - state = load_feature_state(feature) + snapshot = load_queue_snapshot(state_root) + feature = snapshot.feature(feature_id) + state = snapshot.feature_states[feature_id] if not state.get("integration_blocked_reason"): - raise StateError(f"feature is not blocked: {feature_slug}") + raise StateError(f"feature is not blocked: {feature_id}") timestamp = utc_now() append_history(state, "feature-released", timestamp) state.pop("integration_blocked_reason", None) state.pop("integration_blocked_at", None) write_feature_state(feature, state) - return f"FEATURE_RELEASED={feature_slug}" + return f"FEATURE_RELEASED={feature_id}" def integrate_feature( state_root: Path, repo_root: Path, - feature_slug: str, + feature_id: FeatureId, feature_head: str, verified: str, main_verified: str, @@ -1589,53 +1845,66 @@ def integrate_feature( repo_root = resolve_repo_root(repo_root) with locked_state(state_root): - slugs = load_queue(state_root / "queue.md") - if feature_slug not in slugs: - raise StateError(f"feature is not queued: {feature_slug}") - queued_features = [load_feature(state_root, slug) for slug in slugs] - requested = next( - feature for feature in queued_features if feature.slug == feature_slug + snapshot = load_queue_snapshot(state_root) + requested = snapshot.feature(feature_id) + requested_state = snapshot.feature_states[feature_id] + integration_commit = validated_integration_commit( + feature_id, + requested_state, ) - requested_state = load_feature_state(requested) - if requested_state.get("integration_commit"): + if integration_commit: + integration_commit = normalize_commit( + repo_root, + integration_commit, + str(FeatureIntegrationId(feature_id)), + ) warnings = cleanup_integrated_feature_worktrees( repo_root, state_root, requested ) output = [ - f"INTEGRATED={feature_slug}", - f"MAIN_INTEGRATION_COMMIT={requested_state['integration_commit']}", + f"INTEGRATED={feature_id}", + f"MAIN_INTEGRATION_COMMIT={integration_commit}", ] output.extend(f"WARNING={warning}" for warning in warnings) return "\n".join(output) if requested_state.get("integration_blocked_reason"): raise StateError( - f"feature {feature_slug} is blocked: " + f"feature {feature_id} is blocked: " f"{requested_state['integration_blocked_reason']}" ) - first_pending: Optional[Feature] = None - for feature in queued_features: - if not load_feature_state(feature).get("integration_commit"): - first_pending = feature - break - if first_pending is None: + integration_frontier = snapshot.scheduler.integration_frontier + if integration_frontier is None: return "NOOP: all features integrated" - if first_pending.slug != feature_slug: + if integration_frontier.feature != feature_id: raise StateError( - f"cannot integrate {feature_slug}: earlier feature " - f"{first_pending.slug} is not integrated" + f"cannot integrate {feature_id}: earlier feature " + f"{integration_frontier.feature} is not integrated" ) - if requested.state != "ready-to-integrate": + requested_status = snapshot.scheduler.feature_state(feature_id) + if requested_status != "ready-to-integrate": raise StateError( - f"feature {feature_slug} is not ready to integrate: {requested.state}" + f"feature {feature_id} is not ready to integrate: " + f"{requested_status}" ) if requested.partial and not allow_partial: raise StateError( - f"feature {feature_slug} is partial; pass --allow-partial explicitly" + f"feature {feature_id} is partial; pass --allow-partial explicitly" ) + claims = active_claims( + [ + snapshot.feature(queued_feature_id) + for queued_feature_id in snapshot.feature_ids + ] + ) + if any( + ticket.metadata.get("isolation") == "in-place" + for _, ticket in claims + ): + return "BUSY" feature_branch = str( - requested_state.get("feature_branch", f"feature/{feature_slug}") + requested_state.get("feature_branch", f"feature/{feature_id}") ) if not branch_exists(repo_root, feature_branch): raise StateError(f"feature branch not found: {feature_branch}") @@ -1714,7 +1983,7 @@ def integrate_feature( "merge", "--no-ff", "-m", - f"Integrate feature {feature_slug}", + f"Integrate feature {feature_id}", feature_branch, ) if merge.returncode != 0: @@ -1761,87 +2030,273 @@ def integrate_feature( repo_root, state_root, requested ) output = [ - f"INTEGRATED={feature_slug}", + f"INTEGRATED={feature_id}", f"MAIN_INTEGRATION_COMMIT={integration_commit}", ] output.extend(f"WARNING={warning}" for warning in warnings) return "\n".join(output) +VERIFICATION_JSON_HELP = ( + "verification JSON fields: version=1, kind=verification, commit, " + "result=pass, command, exit_code=0, output, output_sha256." +) +REVIEW_JSON_HELP = ( + "review JSON fields: version=1, kind=review, commit, base, " + "standards=pass, spec=pass, report, report_sha256." +) +EVIDENCE_HELP = f"{VERIFICATION_JSON_HELP} {REVIEW_JSON_HELP}" +STATUS_OUTPUT_HELP = ( + "stdout/0 outcome NO FEATURES, or records: INTEGRATION_FRONTIER with STATE and " + "WAITING_ON; FEATURE with STATE and PARTIAL; TICKET_FRONTIER; CLAIM with OWNER, " + "HEARTBEAT, STALE, ISOLATION, and WORKSPACE; BLOCKED and TICKET_ERROR with REASON; " + "BLOCKED_DEPENDENCY with WAITING_ON; FEATURE_BLOCKED with REASON; " + "MAIN_INTEGRATION_COMMIT." +) +CLAIM_OUTPUT_HELP = ( + "successful assignment keys: FEATURE, TICKET, CONTROL_ROOT, STATE_ROOT, " + "WORKSPACE, BRANCH, BASE, ISOLATION. Other stdout/0 outcomes: NO FEATURES, " + "NOOP, BUSY, RETRY, INTEGRATION_REQUIRED." +) +INTEGRATE_OUTPUT_HELP = ( + "successful stdout keys: INTEGRATED, MAIN_INTEGRATION_COMMIT, followed by zero or " + "more WARNING lines for retained worktrees. Other stdout/0 outcomes: NOOP, RETRY." +) + + def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="main_loop.py") + parser = argparse.ArgumentParser( + prog="main_loop.py", + description=( + "Operate the locked global ticket DAG and its serial feature " + "integration queue." + ), + epilog=( + "Normal scheduler outcomes are written to stdout with exit code 0; " + "invalid input or state is written to stderr with exit code 2." + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) subparsers = parser.add_subparsers(dest="command", required=True) - enqueue = subparsers.add_parser("enqueue") - enqueue.add_argument("--state-root", default=".scratch") - enqueue.add_argument("--feature", required=True) + def command_parser( + name: str, + summary: str, + *, + description: Optional[str] = None, + epilog: Optional[str] = None, + ) -> argparse.ArgumentParser: + return subparsers.add_parser( + name, + help=summary, + description=description or summary, + epilog=epilog, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) - status = subparsers.add_parser("status") - status.add_argument("--state-root", default=".scratch") + def add_state_root(command: argparse.ArgumentParser) -> None: + command.add_argument( + "--state-root", + default=".scratch", + help=( + "shared scheduler state; use <PROJECT_ROOT>/.scratch before " + "claim and the returned absolute STATE_ROOT afterwards" + ), + ) - claim = subparsers.add_parser("claim") - claim.add_argument("--state-root", default=".scratch") - claim.add_argument("--repo-root", default=".") - claim.add_argument("--owner", required=True) + def add_repo_root(command: argparse.ArgumentParser) -> None: + command.add_argument( + "--repo-root", + default=".", + help="control Git checkout containing the shared repository", + ) + + enqueue = command_parser( + "enqueue", + "atomically validate and append one or more queued features", + ) + add_state_root(enqueue) + enqueue.add_argument( + "--feature", + required=True, + action="append", + help="FeatureId to append; repeat for one atomic dependency batch", + ) + + status = command_parser( + "status", + "report qualified ticket and integration frontiers", + epilog=STATUS_OUTPUT_HELP, + ) + add_state_root(status) + + claim = command_parser( + "claim", + "resume this owner or claim the first globally runnable ticket", + description=( + "Resume this owner's active claim or select the first globally " + "runnable ticket. In-place isolation is queue-global; worktrees can " + "run across features." + ), + epilog=CLAIM_OUTPUT_HELP, + ) + add_state_root(claim) + add_repo_root(claim) claim.add_argument( - "--isolation", choices=("auto", "in-place", "worktree"), default="auto" + "--owner", + required=True, + help="globally unique stable owner for this session", + ) + claim.add_argument( + "--isolation", + choices=("auto", "in-place", "worktree"), + default="auto", + help="workspace isolation; auto uses global active claims", + ) + claim.add_argument( + "--main-branch", + default="main", + help="branch containing integrated features", ) - claim.add_argument("--main-branch", default="main") - finish = subparsers.add_parser("finish") - finish.add_argument("--state-root", default=".scratch") - finish.add_argument("--repo-root", default=".") - finish.add_argument("--feature", required=True) - finish.add_argument("--ticket", required=True) - finish.add_argument("--owner", required=True) + finish = command_parser( + "finish", + "resolve, block, release, or skip an owned ticket", + description=( + "Transition an owned ticket. Resolved requires commit-bound " + "verification and review evidence; blocked and skipped require a " + "reason." + ), + epilog=EVIDENCE_HELP, + ) + add_state_root(finish) + add_repo_root(finish) finish.add_argument( - "--result", choices=("resolved", "blocked", "released", "skipped"), required=True + "--ticket", + required=True, + help="qualified <feature>/<NN> TicketId", + ) + finish.add_argument("--owner", required=True, help="current claim owner") + finish.add_argument( + "--result", + choices=("resolved", "blocked", "released", "skipped"), + required=True, + help="target ticket status transition", + ) + finish.add_argument( + "--implementation-commit", + default="", + help="ticket branch HEAD for a resolved result", + ) + finish.add_argument( + "--feature-head", + default="", + help="verified feature branch head used as the merge target", + ) + finish.add_argument( + "--review-base", + default="", + help="fixed review base, equal to the verified feature head", + ) + finish.add_argument( + "--verified", + default="", + help="path to ticket verification JSON for a resolved result", + ) + finish.add_argument( + "--reviewed", + default="", + help="path to ticket review JSON for a resolved result", + ) + finish.add_argument( + "--reason", + default="", + help="required explanation for blocked or skipped", ) - finish.add_argument("--implementation-commit", default="") - finish.add_argument("--feature-head", default="") - finish.add_argument("--review-base", default="") - finish.add_argument("--verified", default="") - finish.add_argument("--reviewed", default="") - finish.add_argument("--reason", default="") - heartbeat = subparsers.add_parser("heartbeat") - heartbeat.add_argument("--state-root", default=".scratch") - heartbeat.add_argument("--feature", required=True) - heartbeat.add_argument("--ticket", required=True) - heartbeat.add_argument("--owner", required=True) + heartbeat = command_parser( + "heartbeat", + "renew an active claim lease for its current owner", + ) + add_state_root(heartbeat) + heartbeat.add_argument("--ticket", required=True, help="qualified TicketId") + heartbeat.add_argument("--owner", required=True, help="current claim owner") - reclaim = subparsers.add_parser("reclaim") - reclaim.add_argument("--state-root", default=".scratch") - reclaim.add_argument("--repo-root", default=".") - reclaim.add_argument("--feature", required=True) - reclaim.add_argument("--ticket", required=True) - reclaim.add_argument("--owner", required=True) + reclaim = command_parser( + "reclaim", + "transfer a stale claim while preserving its workspace and base", + ) + add_state_root(reclaim) + add_repo_root(reclaim) + reclaim.add_argument("--ticket", required=True, help="qualified TicketId") + reclaim.add_argument("--owner", required=True, help="new unique owner") - block_feature = subparsers.add_parser("block-feature") - block_feature.add_argument("--state-root", default=".scratch") - block_feature.add_argument("--feature", required=True) - block_feature.add_argument("--reason", required=True) + block_feature = command_parser( + "block-feature", + "block only a feature integration frontier, not later development", + ) + add_state_root(block_feature) + block_feature.add_argument("--feature", required=True, help="FeatureId") + block_feature.add_argument("--reason", required=True, help="blocking reason") - release_feature = subparsers.add_parser("release-feature") - release_feature.add_argument("--state-root", default=".scratch") - release_feature.add_argument("--feature", required=True) + release_feature = command_parser( + "release-feature", + "clear a feature integration block", + ) + add_state_root(release_feature) + release_feature.add_argument("--feature", required=True, help="FeatureId") - release_ticket = subparsers.add_parser("release-ticket") - release_ticket.add_argument("--state-root", default=".scratch") - release_ticket.add_argument("--feature", required=True) - release_ticket.add_argument("--ticket", required=True) - release_ticket.add_argument("--reason", required=True) + release_ticket = command_parser( + "release-ticket", + "return a blocked ticket to ready without its lost owner", + ) + add_state_root(release_ticket) + release_ticket.add_argument("--ticket", required=True, help="qualified TicketId") + release_ticket.add_argument("--reason", required=True, help="recovery reason") - integrate = subparsers.add_parser("integrate") - integrate.add_argument("--state-root", default=".scratch") - integrate.add_argument("--repo-root", default=".") - integrate.add_argument("--feature", required=True) - integrate.add_argument("--feature-head", required=True) - integrate.add_argument("--verified", required=True) - integrate.add_argument("--main-verified", required=True) - integrate.add_argument("--reviewed", required=True) - integrate.add_argument("--main-branch", default="main") - integrate.add_argument("--allow-partial", action="store_true") + integrate = command_parser( + "integrate", + "merge only the strict integration frontier under the global lock", + description=( + "Integrate only the strict integration frontier. The feature must " + "be ready, synchronized with main, and backed by fresh verification " + "and review evidence." + ), + epilog=f"{EVIDENCE_HELP} {INTEGRATE_OUTPUT_HELP}", + ) + add_state_root(integrate) + add_repo_root(integrate) + integrate.add_argument("--feature", required=True, help="frontier FeatureId") + integrate.add_argument( + "--feature-head", + required=True, + help="verified feature branch HEAD", + ) + integrate.add_argument( + "--verified", + required=True, + help="path to feature verification JSON", + ) + integrate.add_argument( + "--main-verified", + required=True, + help="path to main-candidate verification JSON", + ) + integrate.add_argument( + "--reviewed", + required=True, + help="path to feature review JSON", + ) + integrate.add_argument( + "--main-branch", + default="main", + help="serial integration target branch", + ) + integrate.add_argument( + "--allow-partial", + action="store_true", + help="explicitly authorize integration when tickets were skipped", + ) return parser @@ -1850,7 +2305,10 @@ def main(argv: list[str]) -> int: state_root = Path(args.state_root) try: if args.command == "enqueue": - message = enqueue_feature(state_root, args.feature) + message = enqueue_features( + state_root, + tuple(FeatureId.parse(raw) for raw in args.feature), + ) elif args.command == "status": message = status_report(state_root) elif args.command == "claim": @@ -1864,8 +2322,7 @@ def main(argv: list[str]) -> int: elif args.command == "heartbeat": message = heartbeat_ticket( state_root, - args.feature, - args.ticket, + TicketId.parse(args.ticket), args.owner, datetime.now(timezone.utc), ) @@ -1873,34 +2330,32 @@ def main(argv: list[str]) -> int: message = reclaim_ticket( state_root, Path(args.repo_root), - args.feature, - args.ticket, + TicketId.parse(args.ticket), args.owner, datetime.now(timezone.utc), ) elif args.command == "block-feature": message = block_feature_integration( state_root, - args.feature, + FeatureId.parse(args.feature), args.reason, ) elif args.command == "release-feature": message = release_feature_integration( state_root, - args.feature, + FeatureId.parse(args.feature), ) elif args.command == "release-ticket": message = release_blocked_ticket( state_root, - args.feature, - args.ticket, + TicketId.parse(args.ticket), args.reason, ) elif args.command == "integrate": message = integrate_feature( state_root, Path(args.repo_root), - args.feature, + FeatureId.parse(args.feature), args.feature_head, args.verified, args.main_verified, @@ -1912,8 +2367,7 @@ def main(argv: list[str]) -> int: message = finish_resolved_ticket( state_root, Path(args.repo_root), - args.feature, - args.ticket, + TicketId.parse(args.ticket), args.owner, args.implementation_commit, args.feature_head, @@ -1924,15 +2378,14 @@ def main(argv: list[str]) -> int: elif args.command == "finish": message = finish_nonresolved_ticket( state_root, - args.feature, - args.ticket, + TicketId.parse(args.ticket), args.owner, args.result, args.reason, ) else: raise StateError(f"unhandled command: {args.command}") - except (OSError, StateError, UnicodeError) as exc: + except (OSError, SchedulerError, StateError, UnicodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 print(message) diff --git a/skills/cook-it-through/scripts/main_loop_scheduler.py b/skills/cook-it-through/scripts/main_loop_scheduler.py new file mode 100644 index 00000000..22a52cab --- /dev/null +++ b/skills/cook-it-through/scripts/main_loop_scheduler.py @@ -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) diff --git a/skills/cook-it-through/workflows/feature-integration.md b/skills/cook-it-through/workflows/feature-integration.md new file mode 100644 index 00000000..0f82bac2 --- /dev/null +++ b/skills/cook-it-through/workflows/feature-integration.md @@ -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 的重试必须幂等。 diff --git a/skills/cook-it-through/workflows/feature-planning.md b/skills/cook-it-through/workflows/feature-planning.md new file mode 100644 index 00000000..63837500 --- /dev/null +++ b/skills/cook-it-through/workflows/feature-planning.md @@ -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` 恢复;不得把问题写入稳定知识。 diff --git a/skills/cook-it-through/workflows/single-session.md b/skills/cook-it-through/workflows/single-session.md new file mode 100644 index 00000000..f93427e8 --- /dev/null +++ b/skills/cook-it-through/workflows/single-session.md @@ -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。 diff --git a/skills/cook-it-through/workflows/ticket-execution.md b/skills/cook-it-through/workflows/ticket-execution.md new file mode 100644 index 00000000..51a32d59 --- /dev/null +++ b/skills/cook-it-through/workflows/ticket-execution.md @@ -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。 diff --git a/templates/AGENT_RULES.template.md b/templates/AGENT_RULES.template.md index 0038ab53..a380a46c 100644 --- a/templates/AGENT_RULES.template.md +++ b/templates/AGENT_RULES.template.md @@ -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 --> diff --git a/templates/README.md b/templates/README.md index 86c25de3..ca174eaa 100644 --- a/templates/README.md +++ b/templates/README.md @@ -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 diff --git a/test/test_main_loop_cli.py b/test/test_main_loop_cli.py index 91ea7e03..c75a40de 100644 --- a/test/test_main_loop_cli.py +++ b/test/test_main_loop_cli.py @@ -11,10 +11,10 @@ from unittest import mock ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "main_loop.py" +SCRIPT = ROOT / "skills" / "cook-it-through" / "scripts" / "main_loop.py" _MAIN_LOOP_SPEC = importlib.util.spec_from_file_location( - "playbook_main_loop", ROOT / "scripts" / "main_loop.py" + "playbook_main_loop", SCRIPT ) assert _MAIN_LOOP_SPEC and _MAIN_LOOP_SPEC.loader MAIN_LOOP = importlib.util.module_from_spec(_MAIN_LOOP_SPEC) @@ -133,7 +133,7 @@ def write_ticket( number: str, slug: str, title: str, - blocked_by: str = "None - can start immediately.", + blocked_by: str = "None", status: str = "ready-for-agent", ) -> Path: path = issues / f"{number}-{slug}.md" @@ -169,6 +169,630 @@ def write_feature(root: Path, slug: str) -> Path: class MainLoopCliTests(unittest.TestCase): + def test_existing_ticket_branch_must_see_an_integrated_dependency(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + provider_issues = write_feature(root, "provider") + consumer_issues = write_feature(root, "consumer") + write_ticket( + provider_issues, + "01", + "provider", + "Provider", + status="resolved", + ) + write_ticket( + consumer_issues, + "01", + "consumer", + "Consumer", + blocked_by="provider@integrated", + ) + enqueue_provider = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "provider", + cwd=root, + ) + self.assertEqual(enqueue_provider.returncode, 0, msg=enqueue_provider.stderr) + + base = run_git(root, "rev-parse", "main").stdout.strip() + self.assertEqual( + run_git(root, "branch", "feature/consumer", base).returncode, + 0, + ) + self.assertEqual( + run_git(root, "branch", "ticket/consumer/01-consumer", base).returncode, + 0, + ) + (root / "provider.txt").write_text( + "integrated\n", encoding="utf-8", newline="\n" + ) + run_git(root, "add", "provider.txt") + committed = run_git(root, "commit", "-m", "integrate provider") + self.assertEqual(committed.returncode, 0, msg=committed.stderr) + integration_commit = run_git(root, "rev-parse", "main").stdout.strip() + self.assertEqual( + run_git( + root, + "branch", + "-f", + "feature/consumer", + integration_commit, + ).returncode, + 0, + ) + provider = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("provider"), + ) + MAIN_LOOP.write_feature_state( + provider, + {"integration_commit": integration_commit}, + ) + enqueue_consumer = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "consumer", + cwd=root, + ) + self.assertEqual(enqueue_consumer.returncode, 0, msg=enqueue_consumer.stderr) + + retry = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + + self.assertEqual(retry.returncode, 0, msg=retry.stderr) + self.assertIn( + "RETRY: dependency integration is not visible", + retry.stdout, + ) + retry_context = parse_assignments(retry.stdout) + self.assertEqual(retry_context["BRANCH_HEAD"], integration_commit) + self.assertEqual( + retry_context["TICKET_BRANCH"], + "ticket/consumer/01-consumer", + ) + self.assertEqual(retry_context["TICKET_BRANCH_HEAD"], base) + self.assertEqual( + retry_context["SYNC_BRANCH"], + "ticket/consumer/01-consumer", + ) + synced = subprocess.run( + retry_context["SYNC_COMMAND"], + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + self.assertEqual(synced.returncode, 0, msg=synced.stderr) + + claimed = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + self.assertEqual(claimed.returncode, 0, msg=claimed.stderr) + self.assertEqual( + parse_assignments(claimed.stdout)["TICKET"], + "consumer/01", + ) + + def test_integrated_dependency_requires_visibility_from_the_consumer_branch(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + provider_issues = write_feature(root, "provider") + consumer_issues = write_feature(root, "consumer") + write_ticket( + provider_issues, + "01", + "provider", + "Provider", + status="resolved", + ) + write_ticket( + consumer_issues, + "01", + "consumer", + "Consumer", + blocked_by="provider@integrated", + ) + enqueue_provider = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "provider", + cwd=root, + ) + self.assertEqual( + enqueue_provider.returncode, + 0, + msg=enqueue_provider.stderr, + ) + + base = run_git(root, "rev-parse", "main").stdout.strip() + created = run_git(root, "branch", "feature/consumer", base) + self.assertEqual(created.returncode, 0, msg=created.stderr) + (root / "provider.txt").write_text( + "integrated\n", encoding="utf-8", newline="\n" + ) + run_git(root, "add", "provider.txt") + committed = run_git(root, "commit", "-m", "integrate provider") + self.assertEqual(committed.returncode, 0, msg=committed.stderr) + integration_commit = run_git(root, "rev-parse", "main").stdout.strip() + provider = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("provider"), + ) + MAIN_LOOP.write_feature_state( + provider, + {"integration_commit": integration_commit}, + ) + enqueue_consumer = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "consumer", + cwd=root, + ) + self.assertEqual( + enqueue_consumer.returncode, + 0, + msg=enqueue_consumer.stderr, + ) + + retry = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + + self.assertEqual(retry.returncode, 0, msg=retry.stderr) + self.assertIn( + "RETRY: dependency integration is not visible", + retry.stdout, + ) + retry_context = parse_assignments(retry.stdout) + self.assertEqual(retry_context["TICKET"], "consumer/01") + self.assertEqual( + retry_context["DEPENDENCY"], + "provider@integrated", + ) + self.assertEqual(retry_context["BRANCH"], "feature/consumer") + self.assertEqual(retry_context["BRANCH_HEAD"], base) + self.assertEqual(retry_context["MAIN_HEAD"], integration_commit) + sync_workspace = Path(retry_context["WORKSPACE"]) + self.assertEqual( + sync_workspace, + root / ".scratch/worktrees/_integration/consumer", + ) + self.assertIn("worktree add", retry_context["SYNC_COMMAND"]) + self.assertIn(str(sync_workspace), retry_context["SYNC_COMMAND"]) + self.assertIn("feature/consumer", retry_context["SYNC_COMMAND"]) + consumer_ticket = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("consumer"), + ).tickets[MAIN_LOOP.TicketId.parse("consumer/01")] + self.assertEqual(consumer_ticket.status, "ready-for-agent") + + synced = subprocess.run( + retry_context["SYNC_COMMAND"], + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + self.assertEqual(synced.returncode, 0, msg=synced.stderr) + self.assertEqual( + run_git(sync_workspace, "branch", "--show-current").stdout.strip(), + "feature/consumer", + ) + + claimed = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + + self.assertEqual(claimed.returncode, 0, msg=claimed.stderr) + self.assertEqual( + parse_assignments(claimed.stdout)["TICKET"], + "consumer/01", + ) + + def test_global_claim_scans_later_features_when_earlier_frontier_is_claimed(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + for feature in ("alpha", "beta"): + issues = write_feature(root, feature) + write_ticket( + issues, + "01", + f"{feature}-ticket", + feature.title(), + blocked_by="None", + ) + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + + first = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "alpha-owner", + "--isolation", + "worktree", + cwd=root, + ) + second = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "beta-owner", + "--isolation", + "auto", + cwd=root, + ) + + self.assertEqual(first.returncode, 0, msg=first.stderr) + self.assertEqual(second.returncode, 0, msg=second.stderr) + self.assertEqual(parse_assignments(first.stdout)["TICKET"], "alpha/01") + self.assertEqual(parse_assignments(second.stdout)["TICKET"], "beta/01") + self.assertEqual( + parse_assignments(second.stdout)["ISOLATION"], + "worktree", + ) + self.assertNotEqual( + parse_assignments(first.stdout)["WORKSPACE"], + parse_assignments(second.stdout)["WORKSPACE"], + ) + + def test_cross_feature_ticket_dependency_becomes_claimable_after_skip(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + provider_issues = write_feature(root, "provider") + consumer_issues = write_feature(root, "consumer") + write_ticket(provider_issues, "01", "provider", "Provider") + write_ticket( + consumer_issues, + "01", + "consumer", + "Consumer", + blocked_by="provider/01", + ) + for feature in ("provider", "consumer"): + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + + provider_claim = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "provider-owner", + "--isolation", + "worktree", + cwd=root, + ) + blocked_consumer = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + skipped = run_cli( + "finish", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--ticket", + "provider/01", + "--owner", + "provider-owner", + "--result", + "skipped", + "--reason", + "explicit product decision", + cwd=root, + ) + consumer_claim = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + + self.assertEqual(provider_claim.returncode, 0, msg=provider_claim.stderr) + self.assertEqual( + parse_assignments(provider_claim.stdout)["TICKET"], + "provider/01", + ) + self.assertEqual(blocked_consumer.stdout.strip(), "BUSY") + self.assertEqual(skipped.returncode, 0, msg=skipped.stderr) + self.assertEqual(consumer_claim.returncode, 0, msg=consumer_claim.stderr) + self.assertEqual( + parse_assignments(consumer_claim.stdout)["TICKET"], + "consumer/01", + ) + + def test_integration_dependency_requires_a_persisted_integration_commit(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + provider_issues = write_feature(root, "provider") + consumer_issues = write_feature(root, "consumer") + write_ticket( + provider_issues, + "01", + "provider", + "Provider", + status="resolved", + ) + write_ticket( + consumer_issues, + "01", + "consumer", + "Consumer", + blocked_by="provider@integrated", + ) + for feature in ("provider", "consumer"): + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + + before = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + self.assertEqual( + before.stdout.strip(), + "INTEGRATION_REQUIRED=provider@integrated", + ) + + provider = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("provider"), + ) + main_head = run_git(root, "rev-parse", "main").stdout.strip() + MAIN_LOOP.write_feature_state( + provider, + {"integration_commit": main_head}, + ) + after = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "consumer-owner", + "--isolation", + "worktree", + cwd=root, + ) + + self.assertEqual(after.returncode, 0, msg=after.stderr) + self.assertEqual( + parse_assignments(after.stdout)["TICKET"], + "consumer/01", + ) + + def test_invalid_integration_commit_is_rejected_before_status_output(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + provider_issues = write_feature(root, "provider") + write_ticket(provider_issues, "01", "provider", "Provider", status="resolved") + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "provider", + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + provider = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("provider"), + ) + MAIN_LOOP.write_feature_state( + provider, + {"integration_commit": "not-a-git-object"}, + ) + + status = run_cli("status", "--state-root", ".scratch", cwd=root) + + self.assertEqual(status.returncode, 2) + self.assertIn("invalid integration_commit", status.stderr) + + def test_status_separates_ticket_and_integration_frontiers(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + alpha_issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") + gamma_issues = write_feature(root, "gamma") + write_ticket(alpha_issues, "01", "alpha", "Alpha") + write_ticket( + beta_issues, + "01", + "beta", + "Beta", + blocked_by="alpha/01", + ) + write_ticket(gamma_issues, "01", "gamma", "Gamma") + for feature in ("alpha", "beta", "gamma"): + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + claimed = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "alpha-owner", + "--isolation", + "worktree", + cwd=root, + ) + self.assertEqual(claimed.returncode, 0, msg=claimed.stderr) + + status = run_cli("status", "--state-root", ".scratch", cwd=root) + + self.assertEqual(status.returncode, 0, msg=status.stderr) + self.assertIn( + "INTEGRATION_FRONTIER=alpha@integrated STATE=waiting " + "WAITING_ON=alpha/01", + status.stdout, + ) + self.assertIn("CLAIM=alpha/01 OWNER=alpha-owner", status.stdout) + self.assertIn("TICKET_FRONTIER=gamma/01", status.stdout) + self.assertIn( + "BLOCKED_DEPENDENCY=beta/01 WAITING_ON=alpha/01", + status.stdout, + ) + + def test_parallel_processes_claim_tickets_across_features(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + for feature in ("alpha", "beta"): + issues = write_feature(root, feature) + write_ticket(issues, "01", feature, feature.title()) + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + + processes = [ + start_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + f"session-{index}", + "--isolation", + "worktree", + cwd=root, + ) + for index in range(2) + ] + results = [process.communicate(timeout=30) for process in processes] + + for process, (_stdout, stderr) in zip(processes, results): + self.assertEqual(process.returncode, 0, msg=stderr) + contexts = [parse_assignments(stdout) for stdout, _ in results] + self.assertEqual( + {context["TICKET"] for context in contexts}, + {"alpha/01", "beta/01"}, + ) + self.assertEqual(len({context["WORKSPACE"] for context in contexts}), 2) + def test_entrypoint_exposes_only_ticket_native_commands(self): with tempfile.TemporaryDirectory() as tmp_dir: result = run_cli("--help", cwd=Path(tmp_dir)) @@ -188,6 +812,79 @@ class MainLoopCliTests(unittest.TestCase): ): self.assertIn(command, result.stdout) + def test_subcommand_help_explains_the_scheduler_contract(self): + expected = { + "enqueue": "atomically validate", + "status": "ticket and integration frontiers", + "claim": "globally runnable ticket", + "finish": "owned ticket", + "heartbeat": "claim lease", + "reclaim": "stale claim", + "block-feature": "integration frontier", + "release-feature": "feature integration block", + "release-ticket": "blocked ticket", + "integrate": "strict integration frontier", + } + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + root_help = run_cli("--help", cwd=root) + command_help = { + command: run_cli(command, "--help", cwd=root) + for command in expected + } + + self.assertEqual(root_help.returncode, 0, msg=root_help.stderr) + self.assertIn("locked global ticket DAG", root_help.stdout) + self.assertIn("stdout with exit code 0", root_help.stdout) + for command, phrase in expected.items(): + result = command_help[command] + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn(phrase, result.stdout, msg=f"thin help for {command}") + + self.assertIn("qualified <feature>/<NN>", command_help["finish"].stdout) + for schema in ("verification JSON", "review JSON"): + self.assertIn(schema, command_help["finish"].stdout) + self.assertIn(schema, command_help["integrate"].stdout) + for record in ( + "INTEGRATION_FRONTIER", + "FEATURE", + "TICKET_FRONTIER", + "CLAIM", + "OWNER", + "HEARTBEAT", + "STALE", + "ISOLATION", + "WORKSPACE", + "BLOCKED", + "BLOCKED_DEPENDENCY", + "TICKET_ERROR", + "WAITING_ON", + "FEATURE_BLOCKED", + "MAIN_INTEGRATION_COMMIT", + "NO FEATURES", + ): + self.assertIn(record, command_help["status"].stdout) + for assignment in ( + "FEATURE", + "TICKET", + "CONTROL_ROOT", + "STATE_ROOT", + "WORKSPACE", + "BRANCH", + "BASE", + "ISOLATION", + "INTEGRATION_REQUIRED", + ): + self.assertIn(assignment, command_help["claim"].stdout) + for outcome in ( + "INTEGRATED", + "MAIN_INTEGRATION_COMMIT", + "WARNING", + "NOOP", + "RETRY", + ): + self.assertIn(outcome, command_help["integrate"].stdout) + def test_enqueue_writes_a_prettier_stable_queue(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -212,6 +909,79 @@ class MainLoopCliTests(unittest.TestCase): "<!-- main-loop:queue:end -->\n", ) + def test_enqueue_atomically_adds_a_forward_dependency_batch(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + alpha_issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") + write_ticket( + alpha_issues, + "01", + "consumer", + "Consumer", + blocked_by="beta/01", + ) + write_ticket(beta_issues, "01", "provider", "Provider") + + enqueued = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "alpha", + "--feature", + "beta", + cwd=root, + ) + + self.assertEqual(enqueued.returncode, 0, msg=enqueued.stderr) + self.assertEqual( + enqueued.stdout.splitlines(), + ["ENQUEUED=alpha", "ENQUEUED=beta"], + ) + self.assertEqual( + MAIN_LOOP.load_queue(root / ".scratch/queue.md"), + [ + MAIN_LOOP.FeatureId.parse("alpha"), + MAIN_LOOP.FeatureId.parse("beta"), + ], + ) + + def test_enqueue_rejects_an_invalid_batch_without_a_queue_write(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + alpha_issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") + write_ticket( + alpha_issues, + "01", + "alpha", + "Alpha", + blocked_by="beta/01", + ) + write_ticket( + beta_issues, + "01", + "beta", + "Beta", + blocked_by="alpha@integrated", + ) + + rejected = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "alpha", + "--feature", + "beta", + cwd=root, + ) + + self.assertEqual(rejected.returncode, 2) + self.assertIn("dependency cycle", rejected.stderr) + self.assertFalse((root / ".scratch/queue.md").exists()) + def test_cli_rejects_a_caller_supplied_clock(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -321,10 +1091,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -435,36 +1203,6 @@ class MainLoopCliTests(unittest.TestCase): "worktree", cwd=root, ) - busy = run_cli( - "claim", - "--state-root", - ".scratch", - "--repo-root", - ".", - "--owner", - "waiting-owner", - "--isolation", - "worktree", - cwd=root, - ) - blocked = run_cli( - "finish", - "--state-root", - ".scratch", - "--repo-root", - ".", - "--feature", - "alpha", - "--ticket", - "01", - "--owner", - "alpha-owner", - "--result", - "blocked", - "--reason", - "waiting for decision", - cwd=root, - ) beta_claim = run_cli( "claim", "--state-root", @@ -477,11 +1215,25 @@ class MainLoopCliTests(unittest.TestCase): "worktree", cwd=root, ) - + blocked = run_cli( + "finish", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--ticket", + "alpha/01", + "--owner", + "alpha-owner", + "--result", + "blocked", + "--reason", + "waiting for decision", + cwd=root, + ) self.assertEqual(alpha_claim.returncode, 0, msg=alpha_claim.stderr) - self.assertEqual(busy.stdout.strip(), "BUSY") - self.assertEqual(blocked.returncode, 0, msg=blocked.stderr) self.assertEqual(beta_claim.returncode, 0, msg=beta_claim.stderr) + self.assertEqual(blocked.returncode, 0, msg=blocked.stderr) self.assertEqual(parse_assignments(beta_claim.stdout)["FEATURE"], "beta") released = run_cli( @@ -490,10 +1242,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "alpha-owner", "--result", @@ -536,10 +1286,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - feature, "--ticket", - "01", + f"{feature}/01", "--owner", owners[feature], "--result", @@ -594,7 +1342,7 @@ class MainLoopCliTests(unittest.TestCase): ), cwd=root, ) - self.assertIn("INTEGRATION_REQUIRED=alpha", barrier.stdout) + self.assertIn("INTEGRATION_REQUIRED=alpha@integrated", barrier.stdout) self.assertEqual(beta_out_of_order.returncode, 2) self.assertIn("earlier feature alpha", beta_out_of_order.stderr) @@ -751,7 +1499,10 @@ class MainLoopCliTests(unittest.TestCase): self.assertEqual(parse_assignments(beta_claim.stdout)["FEATURE"], "beta") self.assertEqual(released.returncode, 0, msg=released.stderr) self.assertIn("FEATURE_RELEASED=alpha", released.stdout) - self.assertEqual(barrier.stdout.strip(), "INTEGRATION_REQUIRED=alpha") + self.assertEqual( + barrier.stdout.strip(), + "INTEGRATION_REQUIRED=alpha@integrated", + ) def test_reclaim_does_not_allow_the_caller_to_shorten_the_lease(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -786,10 +1537,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-2-20260810-c3d4", "--stale-after-seconds", @@ -807,17 +1556,20 @@ class MainLoopCliTests(unittest.TestCase): root = Path(tmp_dir) init_repo(root) issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") write_ticket(issues, "01", "first", "First") - write_ticket(issues, "02", "second", "Second", "01 - First.") - enqueue = run_cli( - "enqueue", - "--state-root", - ".scratch", - "--feature", - "alpha", - cwd=root, - ) - self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + write_ticket(issues, "02", "second", "Second", "alpha/01") + write_ticket(beta_issues, "01", "beta", "Beta") + for feature_id in ("alpha", "beta"): + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature_id, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) claim = run_cli( "claim", "--state-root", @@ -832,15 +1584,30 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(claim.returncode, 0, msg=claim.stderr) original = parse_assignments(claim.stdout) + beta_claim = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "beta-session", + "--isolation", + "worktree", + cwd=root, + ) + self.assertEqual(beta_claim.returncode, 0, msg=beta_claim.stderr) + self.assertEqual( + parse_assignments(beta_claim.stdout)["TICKET"], + "beta/01", + ) heartbeat = run_cli( "heartbeat", "--state-root", ".scratch", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", cwd=root, @@ -849,16 +1616,16 @@ class MainLoopCliTests(unittest.TestCase): "heartbeat", "--state-root", ".scratch", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-2", cwd=root, ) - feature = MAIN_LOOP.load_feature(root / ".scratch", "alpha") - ticket = feature.tickets["01"] + feature = MAIN_LOOP.load_feature( + root / ".scratch", MAIN_LOOP.FeatureId.parse("alpha") + ) + ticket = feature.tickets[MAIN_LOOP.TicketId.parse("alpha/01")] metadata = dict(ticket.metadata) metadata["heartbeat_at"] = "2000-01-01T00:00:00Z" MAIN_LOOP.update_ticket_state(ticket, metadata=metadata) @@ -874,10 +1641,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-2", cwd=root, @@ -900,10 +1665,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-2", "--result", @@ -921,10 +1684,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-2", "--result", @@ -949,10 +1710,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-3", "--result", @@ -976,7 +1735,7 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(skipped.returncode, 0, msg=skipped.stderr) self.assertIn("FEATURE=alpha STATE=active PARTIAL=yes", final_status.stdout) - self.assertIn("FRONTIER=02", final_status.stdout) + self.assertIn("TICKET_FRONTIER=alpha/02", final_status.stdout) def test_release_ticket_recovers_a_blocked_ticket_without_its_owner(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -1008,10 +1767,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "lost-session", "--result", @@ -1028,10 +1785,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "new-session", "--result", @@ -1044,10 +1799,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "new-session", cwd=root, @@ -1056,10 +1809,8 @@ class MainLoopCliTests(unittest.TestCase): "release-ticket", "--state-root", ".scratch", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--reason", " ", cwd=root, @@ -1068,10 +1819,8 @@ class MainLoopCliTests(unittest.TestCase): "release-ticket", "--state-root", ".scratch", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--reason", "original session is gone", cwd=root, @@ -1080,10 +1829,8 @@ class MainLoopCliTests(unittest.TestCase): "release-ticket", "--state-root", ".scratch", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--reason", "already released", cwd=root, @@ -1113,11 +1860,13 @@ class MainLoopCliTests(unittest.TestCase): self.assertIn("is not blocked", not_blocked_again.stderr) self.assertEqual(reclaimed.returncode, 0, msg=reclaimed.stderr) resumed = parse_assignments(reclaimed.stdout) - self.assertEqual(resumed["TICKET"], "01") + self.assertEqual(resumed["TICKET"], "alpha/01") self.assertEqual(resumed["WORKSPACE"], original["WORKSPACE"]) self.assertEqual(resumed["BRANCH"], original["BRANCH"]) - ticket = MAIN_LOOP.load_feature(root / ".scratch", "alpha").tickets["01"] + ticket = MAIN_LOOP.load_feature( + root / ".scratch", MAIN_LOOP.FeatureId.parse("alpha") + ).tickets[MAIN_LOOP.TicketId.parse("alpha/01")] self.assertEqual(ticket.metadata["last_owner"], "lost-session") self.assertEqual( ticket.metadata["released_reason"], "original session is gone" @@ -1172,7 +1921,7 @@ class MainLoopCliTests(unittest.TestCase): contexts = [parse_assignments(stdout) for _, stdout, _ in results] self.assertEqual( {context["TICKET"] for context in contexts}, - {"01", "02", "03", "04"}, + {"alpha/01", "alpha/02", "alpha/03", "alpha/04"}, ) self.assertEqual( len({context["WORKSPACE"] for context in contexts}), @@ -1183,17 +1932,18 @@ class MainLoopCliTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) init_repo(root) - issues = write_feature(root, "alpha") - write_ticket(issues, "01", "first", "First") - write_ticket(issues, "02", "second", "Second") - enqueue = run_cli( - "enqueue", - "--state-root", - ".scratch", - "--feature", - "alpha", - cwd=root, - ) + for feature in ("alpha", "beta"): + issues = write_feature(root, feature) + write_ticket(issues, "01", feature, feature.title()) + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) worktree_claim = run_cli( "claim", "--state-root", @@ -1221,11 +1971,90 @@ class MainLoopCliTests(unittest.TestCase): ) status = run_cli("status", "--state-root", ".scratch", cwd=root) - self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) self.assertEqual(worktree_claim.returncode, 0, msg=worktree_claim.stderr) self.assertEqual(in_place_claim.returncode, 0, msg=in_place_claim.stderr) self.assertEqual(in_place_claim.stdout.strip(), "BUSY") - self.assertEqual(status.stdout.count("CLAIM=alpha/"), 1) + self.assertEqual(status.stdout.count("CLAIM="), 1) + + def test_active_in_place_claim_blocks_pending_feature_integration(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + init_repo(root) + alpha_issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") + write_ticket( + alpha_issues, + "01", + "alpha", + "Alpha", + status="resolved", + ) + write_ticket(beta_issues, "01", "beta", "Beta") + for feature in ("alpha", "beta"): + enqueue = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + feature, + cwd=root, + ) + self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) + + main_head = run_git(root, "rev-parse", "main").stdout.strip() + created = run_git(root, "branch", "feature/alpha", main_head) + self.assertEqual(created.returncode, 0, msg=created.stderr) + claimed = run_cli( + "claim", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--owner", + "beta-owner", + "--isolation", + "in-place", + cwd=root, + ) + self.assertEqual(claimed.returncode, 0, msg=claimed.stderr) + self.assertEqual( + parse_assignments(claimed.stdout)["TICKET"], + "beta/01", + ) + + integrated = run_cli( + "integrate", + "--state-root", + ".scratch", + "--repo-root", + ".", + "--feature", + "alpha", + "--feature-head", + main_head, + "--verified", + verification_evidence(main_head, "feature tests"), + "--main-verified", + verification_evidence(main_head, "main candidate tests"), + "--reviewed", + review_evidence(main_head, main_head), + cwd=root, + ) + + self.assertEqual(integrated.returncode, 0, msg=integrated.stderr) + self.assertEqual(integrated.stdout.strip(), "BUSY") + self.assertEqual( + run_git(root, "branch", "--show-current").stdout.strip(), + "ticket/beta/01-beta", + ) + alpha = MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("alpha"), + ) + self.assertNotIn( + "integration_commit", + MAIN_LOOP.load_feature_state(alpha), + ) def test_parallel_worktree_claims_retry_when_feature_head_advances(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -1273,8 +2102,8 @@ class MainLoopCliTests(unittest.TestCase): self.assertEqual(second.returncode, 0, msg=second.stderr) first_context = parse_assignments(first.stdout) second_context = parse_assignments(second.stdout) - self.assertEqual(first_context["TICKET"], "01") - self.assertEqual(second_context["TICKET"], "02") + self.assertEqual(first_context["TICKET"], "alpha/01") + self.assertEqual(second_context["TICKET"], "alpha/02") self.assertNotEqual(first_context["WORKSPACE"], second_context["WORKSPACE"]) first_workspace = Path(first_context["WORKSPACE"]) second_workspace = Path(second_context["WORKSPACE"]) @@ -1303,10 +2132,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -1329,10 +2156,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "02", + "alpha/02", "--owner", "session-2", "--result", @@ -1360,10 +2185,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "02", + "alpha/02", "--owner", "session-2", "--result", @@ -1395,10 +2218,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "02", + "alpha/02", "--owner", "session-2", "--result", @@ -1427,10 +2248,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "02", + "alpha/02", "--owner", "session-2", "--result", @@ -1526,10 +2345,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -1576,7 +2393,7 @@ class MainLoopCliTests(unittest.TestCase): init_repo(root) issues = write_feature(root, "alpha") write_ticket(issues, "01", "first", "First") - write_ticket(issues, "02", "second", "Second", "01 - First.") + write_ticket(issues, "02", "second", "Second", "alpha/01") enqueue = run_cli( "enqueue", "--state-root", @@ -1620,14 +2437,14 @@ class MainLoopCliTests(unittest.TestCase): "--owner", "session-2", "--isolation", - "in-place", + "worktree", cwd=root, ) self.assertEqual(claim.returncode, 0, msg=claim.stderr) context = parse_assignments(claim.stdout) self.assertEqual(context["FEATURE"], "alpha") - self.assertEqual(context["TICKET"], "01") + self.assertEqual(context["TICKET"], "alpha/01") self.assertEqual(context["BRANCH"], "ticket/alpha/01-first") self.assertEqual( Path(context["STATE_ROOT"]), @@ -1656,10 +2473,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -1690,7 +2505,7 @@ class MainLoopCliTests(unittest.TestCase): "feature/alpha", ) self.assertIn("FEATURE=alpha STATE=active PARTIAL=no", status.stdout) - self.assertIn("FRONTIER=02", status.stdout) + self.assertIn("TICKET_FRONTIER=alpha/02", status.stdout) def test_enqueue_and_status_report_all_frontier_tickets(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -1703,7 +2518,7 @@ class MainLoopCliTests(unittest.TestCase): "03", "combine", "Combine", - "01 - First; 02 - Second.", + "alpha/01; alpha/02", ) enqueue = run_cli( @@ -1722,7 +2537,10 @@ class MainLoopCliTests(unittest.TestCase): self.assertEqual(enqueue.stdout.strip(), "ENQUEUED=alpha") self.assertEqual(status.returncode, 0, msg=status.stderr) self.assertIn("FEATURE=alpha STATE=queued PARTIAL=no", status.stdout) - self.assertIn("FRONTIER=01,02", status.stdout) + self.assertIn( + "TICKET_FRONTIER=alpha/01,alpha/02", + status.stdout, + ) queue = (root / ".scratch" / "queue.md").read_text(encoding="utf-8") self.assertIn("- `alpha`", queue) @@ -1740,7 +2558,10 @@ class MainLoopCliTests(unittest.TestCase): cwd=root, ) self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) - ticket = MAIN_LOOP.parse_ticket(ticket_path) + ticket = MAIN_LOOP.parse_ticket( + ticket_path, + MAIN_LOOP.FeatureId.parse("alpha"), + ) MAIN_LOOP.update_ticket_state( ticket, status="claimed", @@ -1765,7 +2586,7 @@ class MainLoopCliTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) issues = write_feature(root, "broken") - write_ticket(issues, "01", "broken", "Broken", "99 - Missing.") + write_ticket(issues, "01", "broken", "Broken", "broken/99") result = run_cli( "enqueue", @@ -1777,13 +2598,16 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(result.returncode, 2) - self.assertIn("unknown blocker 99", result.stderr) + self.assertIn("dependency ticket not found: broken/99", result.stderr) self.assertFalse((root / ".scratch" / "queue.md").exists()) def test_enqueue_rejects_partially_parsed_blockers(self): cases = ( "None; 99 - Hidden.", "01 - First; garbage", + "01", + "broken/01 - First", + "broken/01, broken/02", ) for blocked_by in cases: with self.subTest(blocked_by=blocked_by), tempfile.TemporaryDirectory() as tmp_dir: @@ -1808,9 +2632,168 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(result.returncode, 2) - self.assertIn("malformed Blocked by field", result.stderr) + self.assertIn("invalid dependency", result.stderr) self.assertFalse((root / ".scratch" / "queue.md").exists()) + def test_enqueue_rejects_duplicate_self_and_missing_feature_dependencies(self): + cases = ( + ( + "duplicate", + (("01", "first", "None"), ("02", "second", "broken/01; broken/01")), + "broken/02: duplicate dependency broken/01", + ), + ( + "self", + (("01", "first", "broken/01"),), + "broken/01: self dependency broken/01", + ), + ( + "missing-feature", + (("01", "first", "missing/01"),), + "broken/01: dependency feature not queued: missing", + ), + ) + for case, ticket_specs, expected in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + issues = write_feature(root, "broken") + paths = [] + for number, slug, blocked_by in ticket_specs: + paths.append( + write_ticket( + issues, + number, + slug, + slug.title(), + blocked_by=blocked_by, + ) + ) + before = { + path: path.read_text(encoding="utf-8") for path in paths + } + + result = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "broken", + cwd=root, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn(expected, result.stderr) + self.assertFalse((root / ".scratch" / "queue.md").exists()) + self.assertEqual( + {path: path.read_text(encoding="utf-8") for path in paths}, + before, + ) + + def test_enqueue_rejects_a_missing_cross_feature_ticket_atomically(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + beta_issues = write_feature(root, "beta") + write_ticket(beta_issues, "01", "present", "Present") + enqueued = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "beta", + cwd=root, + ) + self.assertEqual(enqueued.returncode, 0, msg=enqueued.stderr) + queue = root / ".scratch" / "queue.md" + queue_before = queue.read_text(encoding="utf-8") + alpha_issues = write_feature(root, "alpha") + ticket = write_ticket( + alpha_issues, + "01", + "missing", + "Missing", + blocked_by="beta/99", + ) + ticket_before = ticket.read_text(encoding="utf-8") + + rejected = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "alpha", + cwd=root, + ) + + self.assertEqual(rejected.returncode, 2) + self.assertIn( + "alpha/01: dependency ticket not found: beta/99", + rejected.stderr, + ) + self.assertEqual(queue.read_text(encoding="utf-8"), queue_before) + self.assertEqual(ticket.read_text(encoding="utf-8"), ticket_before) + + def test_enqueue_validates_an_existing_cross_feature_cycle_before_queue_write(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + alpha_issues = write_feature(root, "alpha") + beta_issues = write_feature(root, "beta") + gamma_issues = write_feature(root, "gamma") + alpha_ticket = write_ticket( + alpha_issues, + "01", + "alpha", + "Alpha", + blocked_by="beta/02", + ) + beta_ticket = write_ticket( + beta_issues, + "02", + "beta", + "Beta", + blocked_by="alpha@integrated", + ) + write_ticket(gamma_issues, "01", "gamma", "Gamma") + queue = root / ".scratch" / "queue.md" + queue.write_text( + MAIN_LOOP.render_queue( + [ + MAIN_LOOP.FeatureId.parse("alpha"), + MAIN_LOOP.FeatureId.parse("beta"), + ] + ), + encoding="utf-8", + newline="\n", + ) + queue_before = queue.read_text(encoding="utf-8") + ticket_before = { + alpha_ticket: alpha_ticket.read_text(encoding="utf-8"), + beta_ticket: beta_ticket.read_text(encoding="utf-8"), + } + + rejected = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "gamma", + cwd=root, + ) + + self.assertEqual(rejected.returncode, 2) + self.assertIn( + "dependency cycle: alpha/01 -> beta/02 -> " + "alpha@integrated -> alpha/01", + rejected.stderr, + ) + self.assertEqual(queue.read_text(encoding="utf-8"), queue_before) + self.assertEqual( + { + path: path.read_text(encoding="utf-8") + for path in ticket_before + }, + ticket_before, + ) + def test_enqueue_rejects_cycles_and_invalid_status_without_writing_queue(self): cases = ("cycle", "invalid-status") for case in cases: @@ -1818,9 +2801,9 @@ class MainLoopCliTests(unittest.TestCase): root = Path(tmp_dir) issues = write_feature(root, "broken") if case == "cycle": - write_ticket(issues, "01", "first", "First", "02 - Second.") - write_ticket(issues, "02", "second", "Second", "01 - First.") - expected = "ticket dependency cycle" + write_ticket(issues, "01", "first", "First", "broken/02") + write_ticket(issues, "02", "second", "Second", "broken/01") + expected = "dependency cycle" else: write_ticket( issues, @@ -1829,7 +2812,7 @@ class MainLoopCliTests(unittest.TestCase): "First", status="done", ) - expected = "invalid status done" + expected = "broken/01: invalid status done" result = run_cli( "enqueue", @@ -1874,6 +2857,35 @@ class MainLoopCliTests(unittest.TestCase): self.assertIn("multiple ticket state blocks", result.stderr) self.assertFalse((root / ".scratch" / "queue.md").exists()) + def test_enqueue_rejects_duplicate_ticket_contract_fields(self): + cases = ( + ("**Blocked by:** None", "multiple Blocked by fields"), + ("**Status:** ready-for-agent", "multiple Status fields"), + ) + for duplicate_line, expected in cases: + with self.subTest(field=duplicate_line), tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + issues = write_feature(root, "broken") + ticket = write_ticket(issues, "01", "first", "First") + ticket.write_text( + ticket.read_text(encoding="utf-8") + duplicate_line + "\n", + encoding="utf-8", + newline="\n", + ) + + result = run_cli( + "enqueue", + "--state-root", + ".scratch", + "--feature", + "broken", + cwd=root, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn(f"broken/01: {expected}", result.stderr) + self.assertFalse((root / ".scratch" / "queue.md").exists()) + def test_status_rejects_duplicate_queue_machine_blocks(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -1972,10 +2984,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2036,10 +3046,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2062,10 +3070,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2139,10 +3145,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2169,10 +3173,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2239,10 +3241,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2390,7 +3390,10 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(enqueue.returncode, 0, msg=enqueue.stderr) state_root = root / ".scratch" - feature = MAIN_LOOP.load_feature(state_root, "alpha") + feature = MAIN_LOOP.load_feature( + state_root, + MAIN_LOOP.FeatureId.parse("alpha"), + ) main_head = run_git(root, "rev-parse", "main").stdout.strip() MAIN_LOOP.write_feature_state( feature, @@ -2408,7 +3411,7 @@ class MainLoopCliTests(unittest.TestCase): recovered = MAIN_LOOP.integrate_feature( state_root, root, - "alpha", + MAIN_LOOP.FeatureId.parse("alpha"), main_head, verification_evidence(main_head, "feature tests"), verification_evidence(main_head, "main candidate tests"), @@ -2449,10 +3452,16 @@ class MainLoopCliTests(unittest.TestCase): with self.assertRaisesRegex(OSError, "injected replace failure"): MAIN_LOOP.atomic_write_text(queue_path, "broken\n") self.assertEqual(queue_path.read_text(encoding="utf-8"), queue_before) - self.assertEqual(MAIN_LOOP.load_queue(queue_path), ["alpha"]) + self.assertEqual( + MAIN_LOOP.load_queue(queue_path), + [MAIN_LOOP.FeatureId.parse("alpha")], + ) - feature = MAIN_LOOP.load_feature(state_root, "alpha") - ticket = feature.tickets["01"] + feature = MAIN_LOOP.load_feature( + state_root, + MAIN_LOOP.FeatureId.parse("alpha"), + ) + ticket = feature.tickets[MAIN_LOOP.TicketId.parse("alpha/01")] with mock.patch.object( MAIN_LOOP.os, "replace", @@ -2466,7 +3475,10 @@ class MainLoopCliTests(unittest.TestCase): ) self.assertEqual(ticket_path.read_text(encoding="utf-8"), ticket_before) self.assertEqual( - MAIN_LOOP.parse_ticket(ticket_path).status, + MAIN_LOOP.parse_ticket( + ticket_path, + MAIN_LOOP.FeatureId.parse("alpha"), + ).status, "ready-for-agent", ) self.assertEqual(list(state_root.rglob("*.tmp")), []) @@ -2531,10 +3543,8 @@ class MainLoopCliTests(unittest.TestCase): ".scratch", "--repo-root", ".", - "--feature", - "alpha", "--ticket", - "01", + "alpha/01", "--owner", "session-1", "--result", @@ -2584,7 +3594,9 @@ class MainLoopCliTests(unittest.TestCase): incomplete_ticket_review.stderr, ) self.assertEqual(resolved.returncode, 0, msg=resolved.stderr) - ticket = MAIN_LOOP.load_feature(root / ".scratch", "alpha").tickets["01"] + ticket = MAIN_LOOP.load_feature( + root / ".scratch", MAIN_LOOP.FeatureId.parse("alpha") + ).tickets[MAIN_LOOP.TicketId.parse("alpha/01")] ticket_evidence = ticket.metadata["evidence"] self.assertEqual(set(ticket_evidence), {"verification", "review"}) for record in ticket_evidence.values(): @@ -2711,7 +3723,10 @@ class MainLoopCliTests(unittest.TestCase): self.assertIn("FEATURE=alpha STATE=integrated PARTIAL=no", status.stdout) self.assertTrue((root / "feature.txt").is_file()) feature_state = MAIN_LOOP.load_feature_state( - MAIN_LOOP.load_feature(root / ".scratch", "alpha") + MAIN_LOOP.load_feature( + root / ".scratch", + MAIN_LOOP.FeatureId.parse("alpha"), + ) ) self.assertEqual( set(feature_state["evidence"]), diff --git a/test/test_main_loop_scheduler.py b/test/test_main_loop_scheduler.py new file mode 100644 index 00000000..1c080ae6 --- /dev/null +++ b/test/test_main_loop_scheduler.py @@ -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() diff --git a/test/test_playbook.py b/test/test_playbook.py index eb3828fb..9c512a40 100644 --- a/test/test_playbook.py +++ b/test/test_playbook.py @@ -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", diff --git a/test/test_template_contracts.py b/test/test_template_contracts.py index ab471b1e..53da1ed8 100644 --- a/test/test_template_contracts.py +++ b/test/test_template_contracts.py @@ -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__":