diff --git a/README.md b/README.md index aa426dfb..927da621 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ project_name = "MyProject" - **配置节存在即启用**:只写需要同步的配置节 - **AGENTS.md**:始终按区块更新(``) - **CLAUDE.md**:自动检测(根目录 → `.claude/`),不存在则创建;注入 `@AGENTS.md` / `@AGENT_RULES.md` +- **.gitignore**:启用 `[sync_rules]` 时更新 Playbook 标记区块,只忽略 `.scratch` 的锁、worktree 和临时文件 - **force**:默认 false,已存在则跳过;设为 true 时强制覆盖(会先备份) 更多说明详见 [templates/README.md](templates/README.md)。 diff --git a/playbook.example.toml b/playbook.example.toml index 20bef447..6262f30e 100644 --- a/playbook.example.toml +++ b/playbook.example.toml @@ -19,7 +19,7 @@ # ============================================================================ [sync_rules] -# 同步 AGENT_RULES.md +# 同步 AGENT_RULES.md,并更新目标项目 .gitignore 中的 .scratch 运行时区块 # force = false # 覆盖已有文件 # no_backup = false # 跳过备份 # date = "2026-04-22" # 替换 {{DATE}} diff --git a/scripts/playbook.py b/scripts/playbook.py index 58ffe317..e10b9ae6 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -339,7 +339,12 @@ def install_snapshot(config: dict, context: dict) -> int: if templates_ci.is_dir(): copytree(templates_ci, templates_dst / "ci") - for name in ("AGENTS.template.md", "AGENT_RULES.template.md", "README.md"): + for name in ( + "AGENTS.template.md", + "AGENT_RULES.template.md", + "gitignore.template", + "README.md", + ): src = templates_root / name if src.is_file(): copy2(src, templates_dst / name) @@ -510,6 +515,8 @@ _AGENTS_BLOCK_START = "" _AGENTS_BLOCK_END = "" _RULES_BLOCK_START = "" _RULES_BLOCK_END = "" +_GITIGNORE_BLOCK_START = "# BEGIN playbook .scratch runtime" +_GITIGNORE_BLOCK_END = "# END playbook .scratch runtime" def replace_marked_block( @@ -751,6 +758,10 @@ def sync_rules_action(config: dict, context: dict) -> int: if not rules_src.is_file(): print(f"ERROR: template not found: {rules_src}", file=sys.stderr) return 2 + gitignore_src = templates_dir / "gitignore.template" + if not gitignore_src.is_file(): + print(f"ERROR: template not found: {gitignore_src}", file=sys.stderr) + return 2 rules_dst = project_root / "AGENT_RULES.md" force = bool(config.get("force", False)) @@ -809,6 +820,16 @@ def sync_rules_action(config: dict, context: dict) -> int: ) log("Created: AGENT_RULES.local.md") + try: + sync_gitignore_block( + gitignore_src, + project_root / ".gitignore", + no_backup, + ) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + return 0 @@ -1046,6 +1067,59 @@ def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None: log("Synced .gitattributes from standards (block).") +def sync_gitignore_block(src: Path, dst: Path, no_backup: bool) -> None: + source_lines = src.read_text(encoding="utf-8").splitlines() + block_lines = [ + _GITIGNORE_BLOCK_START, + *source_lines, + _GITIGNORE_BLOCK_END, + ] + + original_text = dst.read_text(encoding="utf-8") if dst.exists() else "" + if dst.exists(): + original_lines = original_text.splitlines() + start_positions = [ + index + for index, line in enumerate(original_lines) + if line.strip() == _GITIGNORE_BLOCK_START + ] + end_positions = [ + index + for index, line in enumerate(original_lines) + if line.strip() == _GITIGNORE_BLOCK_END + ] + if ( + len(start_positions) != len(end_positions) + or len(start_positions) > 1 + or ( + start_positions + and start_positions[0] >= end_positions[0] + ) + ): + raise ValueError(".gitignore has an invalid playbook .scratch block") + if start_positions: + updated_text = replace_marked_block( + original_text, + block_lines, + _GITIGNORE_BLOCK_START, + _GITIGNORE_BLOCK_END, + ) + else: + content = original_text.rstrip("\n") + if content: + content += "\n\n" + updated_text = content + "\n".join(block_lines) + "\n" + else: + updated_text = "\n".join(block_lines) + "\n" + + if dst.exists() and updated_text == original_text: + log("Unchanged: .gitignore (playbook .scratch block)") + return + backup_path(dst, no_backup) + dst.write_text(updated_text, encoding="utf-8", newline="\n") + log("Synced: .gitignore (playbook .scratch block)") + + def sync_standards_action(config: dict, context: dict) -> int: if "langs" not in config: print("ERROR: langs is required for sync_standards", file=sys.stderr) diff --git a/templates/AGENT_RULES.template.md b/templates/AGENT_RULES.template.md index cdaf4e6c..0038ab53 100644 --- a/templates/AGENT_RULES.template.md +++ b/templates/AGENT_RULES.template.md @@ -123,9 +123,11 @@ setup-matt-pocock-skills -> 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 @@ -139,6 +141,9 @@ setup-matt-pocock-skills - `to-spec` 不重新进行已经完成的需求采访 - `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by` - 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue` +- 本批次全部 `enqueue` 成功后、任何 `claim` 之前,必须把对应的 + `.scratch//spec.md`、`.scratch//issues/*.md` 和 `.scratch/queue.md` + 提交为一个 planning baseline;`to-spec`、`to-tickets` 和 `enqueue` 本身不隐式提交 - `codebase-design` 是 seam、deep module 与依赖分类的词汇来源,供 `to-spec` 和 `tdd` 查阅, 不作为独立会话运行 @@ -196,11 +201,6 @@ setup-matt-pocock-skills 不得手工修改 ticket 的 `Status` 或 `main-loop:ticket-state` 区块;只通过主循环变更。主循环 没有对应命令的状态组合按"卡死与恢复"处理,仍然不手工改。 -`.scratch/` 是否纳入版本控制由项目决定并写入 `AGENT_RULES.local.md`:纳入则 spec、ticket 和 -证据进入历史,可审计、新 clone 能接手队列,代价是状态变更产生提交噪音;排除则历史干净, -代价是状态只存在于本机磁盘、集成后审计线索消失,并发只靠"共享同一文件系统"兜住。无论哪种 -都不得把两份 `.scratch/` 当成同一队列。 - ## 稳定知识维护 只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和集成 @@ -444,6 +444,12 @@ review 后再调用。main 合并冲突时主循环会**自动**把 feature 标 `_integration` worktree;不干净或路径异常的保留并以 `WARNING=` 行报告。已集成的 feature 再次调用时幂等返回 `INTEGRATED=`。 +`integrate` 成功后必须在主干提交本 feature 的最终 workflow state。只暂存 +`.scratch//` 下的持久变更,以及确由本次集成改写时的 `.scratch/queue.md`;不得用 +`git add .scratch` 把其他活动 feature 的并发状态带入。该状态提交必须位于 feature merge commit +之后,不得 amend 或 squash 进 merge commit;`.scratch//.main-loop.json` 中记录的 +`integration_commit` 必须继续指向主循环返回的 `MAIN_INTEGRATION_COMMIT`。 + ready-to-integrate feature 因人工决策暂时不能集成时必须显式记录,否则主循环不会为后序 feature 继续分配开发工作: diff --git a/templates/README.md b/templates/README.md index d1960792..86c25de3 100644 --- a/templates/README.md +++ b/templates/README.md @@ -25,6 +25,7 @@ playbook/ templates/ ├── AGENTS.template.md # 项目主入口与导航模板 ├── AGENT_RULES.template.md # Matt Pocock ticket-native 执行规则模板 +├── gitignore.template # .scratch 持久状态与本机运行时忽略规则 ├── memory-bank/ # 稳定项目知识模板(3 个),不是机器状态源 ├── cpp/ # C++ 工具链模板 └── python/ # Python 工具链模板 @@ -47,6 +48,7 @@ templates/ - `AGENT_RULES.md`:`` 区块内的工程流程由 Playbook 维护,重新同步时刷新;项目自己的补充写在区块外,同步不会动它 - `AGENT_RULES.local.md`:项目私有规则,Playbook 不覆盖 +- `.gitignore`:Playbook 维护专属标记区块,仅忽略 `.scratch` 下的本机运行时产物 - `memory-bank/`:稳定项目定位、技术上下文和当前系统模式,不承载机器状态 Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*` 不会被删除。 @@ -119,12 +121,14 @@ setup-matt-pocock-skills -> 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 ``` 产物和职责: @@ -136,6 +140,12 @@ setup-matt-pocock-skills - `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 可单独 @@ -175,6 +185,7 @@ project/ ├── AGENTS.md # 任一 sync_* 节启用时创建或按区块更新 ├── AGENT_RULES.md # [sync_rules] ├── AGENT_RULES.local.md # 首次成功写入规则时创建,后续由项目维护 +├── .gitignore # [sync_rules] 更新 Playbook .scratch 标记区块 ├── CLAUDE.md # 默认位置;也可使用 .claude/CLAUDE.md ├── .agents/ # [sync_standards] ├── .gitattributes # [sync_standards] 按配置同步 diff --git a/templates/gitignore.template b/templates/gitignore.template new file mode 100644 index 00000000..e21350fa --- /dev/null +++ b/templates/gitignore.template @@ -0,0 +1,8 @@ +# Keep durable Playbook workflow state under .scratch version-controlled. +!/.scratch/ +!/.scratch/** + +# Ignore only machine-local main-loop runtime artifacts. +/.scratch/*.lock +/.scratch/worktrees/ +/.scratch/**/*.tmp diff --git a/test/test_playbook.py b/test/test_playbook.py index 4d256fe7..eb3828fb 100644 --- a/test/test_playbook.py +++ b/test/test_playbook.py @@ -108,6 +108,11 @@ def seed_custom_files(project_root: Path) -> None: encoding="utf-8", newline="\n", ) + (project_root / ".gitignore").write_text( + "build/\n.scratch/\n", + encoding="utf-8", + newline="\n", + ) class PlaybookDeploymentTests(unittest.TestCase): @@ -254,6 +259,7 @@ project_root = "C:\workspace\project" "AGENT_RULES.md", "AGENT_RULES.local.md", "CLAUDE.md", + ".gitignore", ".gitattributes", "memory-bank/project-brief.md", "memory-bank/system-patterns.md", @@ -332,6 +338,22 @@ project_root = "C:\workspace\project" f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录", rules_text, ) + gitignore_text = (project_root / ".gitignore").read_text( + encoding="utf-8" + ) + self.assertIn("build/", gitignore_text) + self.assertEqual( + gitignore_text.count("# BEGIN playbook .scratch runtime"), + 1, + ) + self.assertEqual( + gitignore_text.count("# END playbook .scratch runtime"), + 1, + ) + self.assertIn("!/.scratch/**", gitignore_text) + self.assertIn("/.scratch/*.lock", gitignore_text) + self.assertIn("/.scratch/worktrees/", gitignore_text) + self.assertIn("/.scratch/**/*.tmp", gitignore_text) if install_mode == "snapshot": snapshot_root = project_root / playbook_root @@ -345,6 +367,9 @@ project_root = "C:\workspace\project" self.assertTrue( (snapshot_root / "playbook.example.toml").is_file() ) + self.assertTrue( + (snapshot_root / "templates/gitignore.template").is_file() + ) self.assertFalse( (snapshot_root / "playbook.toml.example").exists() ) @@ -355,6 +380,57 @@ project_root = "C:\workspace\project" else: self.assertFalse((source_root / "SOURCE.md").exists()) + def test_sync_rules_gitignore_block_tracks_durable_scratch_only(self): + with tempfile.TemporaryDirectory() as tmp_dir: + project_root = Path(tmp_dir) / "project" + project_root.mkdir() + seed_custom_files(project_root) + initialized = subprocess.run( + ["git", "init", "-q"], + cwd=project_root, + capture_output=True, + text=True, + ) + self.assertEqual(initialized.returncode, 0, msg=initialized.stderr) + + playbook_root = MODE_ROOTS["snapshot"] + config = write_config(project_root, "snapshot", playbook_root) + result = run_playbook(SCRIPT, config, project_root) + self.assertEqual(result.returncode, 0, msg=f"{result.stdout}{result.stderr}") + + paths = { + "queue": project_root / ".scratch/queue.md", + "spec": project_root / ".scratch/alpha/spec.md", + "evidence": project_root / ".scratch/alpha/evidence/check.json", + "lock": project_root / ".scratch/.main-loop.lock", + "other_lock": project_root / ".scratch/worker.lock", + "worktree": project_root / ".scratch/worktrees/alpha/file.txt", + "temp": project_root / ".scratch/alpha/.spec.md.deadbeef.tmp", + } + for path in paths.values(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("test\n", encoding="utf-8", newline="\n") + + def is_ignored(path: Path) -> bool: + checked = subprocess.run( + [ + "git", + "check-ignore", + "-q", + "--", + path.relative_to(project_root).as_posix(), + ], + cwd=project_root, + capture_output=True, + text=True, + ) + return checked.returncode == 0 + + for name in ("queue", "spec", "evidence"): + self.assertFalse(is_ignored(paths[name]), msg=f"{name} must be tracked") + for name in ("lock", "other_lock", "worktree", "temp"): + self.assertTrue(is_ignored(paths[name]), msg=f"{name} must be ignored") + def test_sync_without_standards_keeps_language_rules(self): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" diff --git a/test/test_template_contracts.py b/test/test_template_contracts.py index f0373bbd..ab471b1e 100644 --- a/test/test_template_contracts.py +++ b/test/test_template_contracts.py @@ -277,17 +277,20 @@ class TemplateContractsTests(unittest.TestCase): for legacy in LEGACY_FLOW_TERMS: self.assertNotIn(legacy, rules) - def test_agent_rules_states_whether_scratch_is_version_controlled(self): - rules = rules_text() - responsibilities = section(rules, "## 文档职责", "## 稳定知识维护") + def test_gitignore_template_tracks_scratch_and_ignores_only_runtime(self): + template = (TEMPLATES / "gitignore.template").read_text(encoding="utf-8") - self.assertIn("AGENT_RULES.local.md", responsibilities) - self.assertIn( - "纳入版本控制", - responsibilities, - msg="the review contract reads spec sources out of .scratch/, so " - "whether it is tracked has to be an explicit project decision", - ) + for durable_rule in ("!/.scratch/", "!/.scratch/**"): + self.assertIn(durable_rule, template) + for runtime_rule in ( + "/.scratch/*.lock", + "/.scratch/worktrees/", + "/.scratch/**/*.tmp", + ): + self.assertIn(runtime_rule, template) + + rules = rules_text() + self.assertNotIn("是否纳入版本控制由项目决定", rules) def test_agent_rules_routes_each_current_matt_on_ramp_to_its_destination(self): rules = rules_text() @@ -315,6 +318,47 @@ class TemplateContractsTests(unittest.TestCase): "the skill's own phase order", ) + def test_agent_rules_commits_planning_baseline_before_claim(self): + rules = rules_text() + main_chain = section(rules, "## 正式工程主链", "## On-ramps 与 detours") + + ordered_steps = ( + "-> to-spec", + "-> to-tickets", + "-> main_loop.py enqueue", + "-> 提交 planning baseline", + "-> main_loop.py claim", + ) + positions = [main_chain.index(step) for step in ordered_steps] + self.assertEqual(positions, sorted(positions)) + for durable_input in ( + "`.scratch//spec.md`", + "`.scratch//issues/*.md`", + "`.scratch/queue.md`", + ): + self.assertIn(durable_input, main_chain) + self.assertIn("任何 `claim` 之前", main_chain) + self.assertIn("不隐式提交", main_chain) + + 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 与证据门禁") + + self.assertLess( + main_chain.index("-> main_loop.py integrate"), + main_chain.index("-> 提交 final workflow state"), + ) + for durable_path in ( + "`.scratch//`", + "`.scratch/queue.md`", + "`.scratch//.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")