✨ feat(playbook): version durable scratch workflow state
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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/<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)
|
||||
|
||||
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/<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")
|
||||
|
||||
Reference in New Issue
Block a user