1import json2import subprocess3from pathlib import Path45import pytest6import tools789def parse(payload):10 return json.loads(payload)111213def test_uniform_lifecycle_tool_names_are_exposed_and_old_idea_names_removed():14 for name in (15 "openspec_idea_create",16 "openspec_idea_enrich",17 "openspec_idea_promote",18 "openspec_task_list",19 "openspec_task_set_status",20 "openspec_change_create",21 "openspec_change_promote",22 "openspec_change_archive",23 "openspec_change_unarchive",24 ):25 assert hasattr(tools, name)2627 for old_name in (28 "openspec_create_idea",29 "openspec_enrich_idea",30 "openspec_promote_idea",31 ):32 assert not hasattr(tools, old_name)333435def test_idea_create_writes_markdown_with_safe_slug_and_collision_suffix(tmp_path):36 first = parse(tools.openspec_idea_create({37 "workdir": str(tmp_path),38 "title": "Agent + Human Feedback Loop!",39 "prompt": "Capture raw user and agent ideas in one lane.",40 "origin": "human",41 "tags": ["orchestration", "feedback"],42 "notes": "Start small.",43 }))44 second = parse(tools.openspec_idea_create({45 "workdir": str(tmp_path),46 "title": "Agent + Human Feedback Loop!",47 "prompt": "A second prompt should not overwrite the first.",48 }))4950 assert first["ok"] is True51 assert first["slug"] == "agent-human-feedback-loop"52 assert second["ok"] is True53 assert second["slug"] == "agent-human-feedback-loop-2"5455 first_path = Path(first["path"])56 second_path = Path(second["path"])57 assert first_path.is_file()58 assert second_path.is_file()59 assert first_path.read_text(encoding="utf-8").startswith("# Agent + Human Feedback Loop!")60 assert "Origin: human" in first_path.read_text(encoding="utf-8")61 assert "Tags: orchestration, feedback" in first_path.read_text(encoding="utf-8")62 assert "Capture raw user and agent ideas" in first_path.read_text(encoding="utf-8")63 assert "A second prompt" in second_path.read_text(encoding="utf-8")646566def test_idea_create_refuses_empty_title_or_prompt_without_writing(tmp_path):67 result = parse(tools.openspec_idea_create({68 "workdir": str(tmp_path),69 "title": " ",70 "prompt": "Something",71 }))7273 assert result["ok"] is False74 assert "title is required" in result["error"]75 assert not (tmp_path / "openspec" / "ideas").exists()767778def test_idea_enrich_inserts_and_replaces_structured_report(tmp_path):79 created = parse(tools.openspec_idea_create({80 "workdir": str(tmp_path),81 "title": "Idea enrichment",82 "prompt": "Assess ideas consistently.",83 }))8485 enriched = parse(tools.openspec_idea_enrich({86 "workdir": str(tmp_path),87 "idea": created["slug"],88 "problem": "Ideas lack consistent evaluation.",89 "proposed_direction": "Use a rubric-backed report.",90 "key_questions": ["Who reviews it?", "When promote?"],91 "feasibility": "High",92 "tshirt_size": "M",93 "size_justification": "Three small tools plus tests.",94 "risks": ["Over-automation"],95 "suggested_next_step": "Promote after review",96 }))97 updated = parse(tools.openspec_idea_enrich({98 "workdir": str(tmp_path),99 "idea": created["slug"],100 "problem": "Updated problem.",101 "feasibility": "Medium",102 "tshirt_size": "S",103 "size_justification": "Reduced scope.",104 "suggested_next_step": "Keep as idea",105 }))106107 assert enriched["ok"] is True108 assert updated["ok"] is True109 content = Path(created["path"]).read_text(encoding="utf-8")110 assert content.count("## Enrichment Report") == 1111 assert "Updated problem." in content112 assert "Ideas lack consistent evaluation." not in content113 assert "Feasibility: Medium" in content114 assert "T-Shirt Size: S" in content115116117def test_idea_promote_creates_change_scaffold_with_traceability_and_refuses_collision(tmp_path):118 created = parse(tools.openspec_idea_create({119 "workdir": str(tmp_path),120 "title": "Promote me",121 "prompt": "Turn a reviewed idea into a change.",122 }))123124 promoted = parse(tools.openspec_idea_promote({125 "workdir": str(tmp_path),126 "idea": created["slug"],127 "change": "promote-reviewed-idea",128 "summary": "Add promotion workflow.",129 }))130 collision = parse(tools.openspec_idea_promote({131 "workdir": str(tmp_path),132 "idea": created["slug"],133 "change": "promote-reviewed-idea",134 }))135136 assert promoted["ok"] is True137 change_dir = Path(promoted["change_path"])138 assert (change_dir / "proposal.md").is_file()139 assert (change_dir / "tasks.md").is_file()140 assert (change_dir / "specs" / "promote-reviewed-idea" / "spec.md").is_file()141 assert "## ADDED Requirements" in (change_dir / "specs" / "promote-reviewed-idea" / "spec.md").read_text(encoding="utf-8")142 assert "Source Idea" in (change_dir / "proposal.md").read_text(encoding="utf-8")143 assert created["relative_path"] in (change_dir / "proposal.md").read_text(encoding="utf-8")144 assert "Turn a reviewed idea into a change." in (change_dir / "proposal.md").read_text(encoding="utf-8")145 assert collision["ok"] is False146 assert "already exists" in collision["error"]147148149def test_change_create_and_promote_support_draft_to_todo_flow(tmp_path):150 created = parse(tools.openspec_change_create({151 "workdir": str(tmp_path),152 "change": "direct-change",153 "title": "Direct Change",154 "summary": "Create a change without an idea first.",155 }))156 assert created["ok"] is True157 change_dir = Path(created["change_path"])158 assert (change_dir / "proposal.md").is_file()159 assert not (change_dir / "tasks.md").exists()160161 promoted = parse(tools.openspec_change_promote({162 "workdir": str(tmp_path),163 "change": "direct-change",164 "tasks": ["Refine scope", "Implement behavior"],165 }))166167 assert promoted["ok"] is True168 assert promoted["status"] == "todo"169 assert (change_dir / "tasks.md").is_file()170 assert (change_dir / "specs" / "direct-change" / "spec.md").is_file()171 assert "- [ ] 1.1 Refine scope" in (change_dir / "tasks.md").read_text(encoding="utf-8")172173174def test_task_list_and_set_status_update_requested_checklist_items(tmp_path):175 parse(tools.openspec_change_create({176 "workdir": str(tmp_path),177 "change": "task-change",178 "title": "Task Change",179 "summary": "Test task lifecycle.",180 "tasks": ["First task", "Second task"],181 "with_spec": True,182 }))183184 listed = parse(tools.openspec_task_list({"workdir": str(tmp_path), "change": "task-change"}))185 assert listed["ok"] is True186 assert [task["id"] for task in listed["tasks"]] == ["1.1", "1.2"]187 assert listed["counts"] == {"total": 2, "done": 0, "todo": 2}188 assert listed["status"] == "todo"189190 updated = parse(tools.openspec_task_set_status({191 "workdir": str(tmp_path),192 "change": "task-change",193 "tasks": ["1.1"],194 "status": "done",195 }))196 assert updated["ok"] is True197 assert updated["counts"] == {"total": 2, "done": 1, "todo": 1}198 assert updated["status"] == "in_progress"199200 done = parse(tools.openspec_task_set_status({201 "workdir": str(tmp_path),202 "change": "task-change",203 "tasks": ["1.2"],204 "status": "done",205 }))206 assert done["counts"] == {"total": 2, "done": 2, "todo": 0}207 assert done["status"] == "done"208209210def test_change_archive_refuses_incomplete_tasks_then_unarchive_restores_active_change(tmp_path):211 parse(tools.openspec_change_create({212 "workdir": str(tmp_path),213 "change": "archive-me",214 "title": "Archive Me",215 "summary": "Archive lifecycle.",216 "tasks": ["Finish this"],217 "with_spec": True,218 }))219220 refused = parse(tools.openspec_change_archive({"workdir": str(tmp_path), "change": "archive-me"}))221 assert refused["ok"] is False222 assert "not complete" in refused["error"]223224 parse(tools.openspec_task_set_status({225 "workdir": str(tmp_path),226 "change": "archive-me",227 "tasks": ["1.1"],228 "status": "done",229 }))230 archived = parse(tools.openspec_change_archive({"workdir": str(tmp_path), "change": "archive-me"}))231 assert archived["ok"] is True232 assert not (tmp_path / "openspec" / "changes" / "archive-me").exists()233 assert (tmp_path / "openspec" / "changes" / "archive" / "archive-me").is_dir()234235 unarchived = parse(tools.openspec_change_unarchive({"workdir": str(tmp_path), "change": "archive-me"}))236 assert unarchived["ok"] is True237 assert (tmp_path / "openspec" / "changes" / "archive-me").is_dir()238 assert not (tmp_path / "openspec" / "changes" / "archive" / "archive-me").exists()239240241def test_generated_scaffolds_validate_with_openspec_cli(tmp_path):242 exe = tools._openspec_bin()243 if not exe:244 pytest.skip("openspec CLI is not available")245246 created = parse(tools.openspec_idea_create({247 "workdir": str(tmp_path),248 "title": "Valid promoted scaffold",249 "prompt": "Generated changes should validate before refinement.",250 }))251 idea_promoted = parse(tools.openspec_idea_promote({252 "workdir": str(tmp_path),253 "idea": created["slug"],254 "change": "valid-promoted-scaffold",255 }))256 change_created = parse(tools.openspec_change_create({257 "workdir": str(tmp_path),258 "change": "valid-created-scaffold",259 "title": "Valid Created Scaffold",260 "summary": "Generated direct changes should validate.",261 "tasks": ["Do the thing"],262 "with_spec": True,263 }))264265 for change in (idea_promoted["change"], change_created["change"]):266 proc = subprocess.run(267 [exe, "validate", change, "--strict", "--json", "--no-interactive"],268 cwd=str(tmp_path),269 text=True,270 stdout=subprocess.PIPE,271 stderr=subprocess.PIPE,272 check=False,273 )274 assert proc.returncode == 0, proc.stdout + proc.stderr275 payload = json.loads(proc.stdout)276 assert payload["summary"]["totals"]["failed"] == 0