1import json2import os3import shutil4import subprocess5from pathlib import Path67import pytest89import tools10import registry11from dashboard import plugin_api121314def test_openspec_instructions_accepts_spec_alias_without_changes(tmp_path):15 exe = tools._openspec_bin()16 if not exe:17 pytest.skip("openspec CLI is not available")1819 subprocess.run([exe, "init", "--tools", "none", str(tmp_path)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)2021 result = json.loads(tools.openspec_instructions({"workdir": str(tmp_path), "artifact": "spec"}))2223 assert result["ok"] is True24 assert result["stdout"]["artifact"] == "specs"25 assert result["stdout"]["fallback"] == "template"26 assert "## ADDED Requirements" in result["stdout"]["content"]27 assert "No changes found" in result["stdout"]["reason"]282930def test_dashboard_init_layout_ensures_plugin_supported_directories(tmp_path):31 openspec_root = tmp_path / "openspec"32 (openspec_root / "changes").mkdir(parents=True)33 (openspec_root / "specs").mkdir()3435 plugin_api._ensure_openspec_layout(tmp_path)3637 assert (openspec_root / "changes").is_dir()38 assert (openspec_root / "changes" / "archive").is_dir()39 assert (openspec_root / "specs").is_dir()40 assert (openspec_root / "ideas").is_dir()414243def test_dashboard_init_fallback_layout_matches_supported_scan_roots(tmp_path, monkeypatch):44 monkeypatch.setattr(plugin_api, "_find_openspec_bin", lambda: None)45 source = {"token": "os_test", "id": "os_test", "name": "demo", "path": str(tmp_path), "created_at": 0}4647 class FakeRegistry:48 def get_source(self, source_id):49 return source if source_id == "os_test" else None5051 monkeypatch.setattr(plugin_api, "_registry", FakeRegistry())5253 response = plugin_api.init_source("os_test")5455 assert response["ok"] is True56 openspec_root = tmp_path / "openspec"57 assert (openspec_root / "changes").is_dir()58 assert (openspec_root / "changes" / "archive").is_dir()59 assert (openspec_root / "specs").is_dir()60 assert (openspec_root / "ideas").is_dir()61 assert response["source"]["valid"] is True626364def test_registry_change_sequence_appends_and_preserves_existing_positions(tmp_path, monkeypatch):65 monkeypatch.setattr(registry, "db_path", lambda: tmp_path / "openspec.db")6667 first = registry.ensure_change_sequence("os_test", ["second", "first"])68 assert first["second"]["position"] == 169 assert first["first"]["position"] == 27071 second = registry.ensure_change_sequence("os_test", ["first", "third", "second"])72 assert second["second"]["position"] == 173 assert second["first"]["position"] == 274 assert second["third"]["position"] == 3757677def test_dashboard_scan_attaches_sequence_without_openspec_files(tmp_path, monkeypatch):78 openspec_root = tmp_path / "openspec" / "changes"79 for name in ["alpha", "beta"]:80 change_dir = openspec_root / name81 change_dir.mkdir(parents=True)82 (change_dir / "proposal.md").write_text(f"# {name.title()}\n", encoding="utf-8")8384 class FakeRegistry:85 def change_token(self, name):86 return "os_" + name8788 def ensure_change_sequence(self, source_id, names):89 assert source_id == "os_test"90 return {name: {"position": i + 1, "firstSeenAt": 1.0, "updatedAt": 1.0} for i, name in enumerate(names)}9192 monkeypatch.setattr(plugin_api, "_registry", FakeRegistry())93 payload = plugin_api._scan(tmp_path, "os_test")9495 assert payload is not None96 assert [ch["name"] for ch in payload["changes"]] == ["alpha", "beta"]97 assert [ch["sequence"]["position"] for ch in payload["changes"]] == [1, 2]98 assert not (openspec_root / "alpha" / ".sequence").exists()99100101def test_title_from_markdown_prefers_closed_frontmatter_title(tmp_path):102 path = tmp_path / "proposal.md"103 path.write_text(104 "---\n"105 "title: Human-readable title\n"106 "created: 2026-08-21\n"107 "---\n"108 "# Heading fallback\n",109 encoding="utf-8",110 )111112 assert plugin_api._title_from_markdown(path, "change-id") == "Human-readable title"113114115def test_title_from_markdown_uses_h1_without_frontmatter(tmp_path):116 path = tmp_path / "proposal.md"117 path.write_text("# Heading title\n\nBody\n", encoding="utf-8")118119 assert plugin_api._title_from_markdown(path, "change-id") == "Heading title"120121122def test_title_from_markdown_uses_identifier_without_title_or_h1(tmp_path):123 path = tmp_path / "proposal.md"124 path.write_text("Body without a title\n", encoding="utf-8")125126 assert plugin_api._title_from_markdown(path, "change-id") == "change-id"127128129def test_title_from_markdown_ignores_nonleading_frontmatter_looking_block(tmp_path):130 path = tmp_path / "proposal.md"131 path.write_text(132 "# Real heading\n"133 "\n"134 "---\n"135 "title: Not metadata\n"136 "---\n",137 encoding="utf-8",138 )139140 assert plugin_api._title_from_markdown(path, "change-id") == "Real heading"141142143def test_title_from_markdown_falls_back_after_unclosed_frontmatter(tmp_path):144 path = tmp_path / "proposal.md"145 path.write_text(146 "---\n"147 "title: Unusable because the block is unclosed\n"148 "# Heading fallback\n",149 encoding="utf-8",150 )151152 assert plugin_api._title_from_markdown(path, "change-id") == "Heading fallback"153154155def test_change_summary_exposes_frontmatter_title(tmp_path):156 change_dir = tmp_path / "change-id"157 change_dir.mkdir()158 (change_dir / "proposal.md").write_text(159 "---\n"160 "title: Change summary title\n"161 "---\n"162 "## Summary\n",163 encoding="utf-8",164 )165166 summary = plugin_api._change_summary(change_dir, "ivault")167168 assert summary is not None169 assert summary["title"] == "Change summary title"170171172def test_idea_summary_exposes_frontmatter_title(tmp_path):173 idea_path = tmp_path / "idea-id.md"174 idea_path.write_text(175 "---\n"176 "title: Idea summary title\n"177 "---\n"178 "## Summary\n",179 encoding="utf-8",180 )181182 summary = plugin_api._idea_summary(idea_path, "ivault")183184 assert summary is not None185 assert summary["title"] == "Idea summary title"186187188def test_change_sequence_tool_declares_order_and_dependencies(tmp_path, monkeypatch):189 monkeypatch.setattr(registry, "db_path", lambda: tmp_path / "openspec.db")190 monkeypatch.setattr(tools, "_registry_module", lambda: registry)191 project = tmp_path / "project"192 changes_root = project / "openspec" / "changes"193 names = ["phase-one", "phase-two", "final-proof"]194 for name in names:195 change_dir = changes_root / name196 change_dir.mkdir(parents=True)197 (change_dir / "proposal.md").write_text(f"# {name}\n", encoding="utf-8")198 registry.add_source(str(project), "demo")199200 result = json.loads(tools.openspec_change_sequence_set({201 "identifier": "demo",202 "changes": names,203 "group_id": "poc",204 "dependencies": {"final-proof": ["phase-one", "phase-two"]},205 }))206207 assert result["ok"] is True208 assert [item["name"] for item in result["changes"]] == names209 assert result["changes"][0]["sequence"]["position"] == 1210 assert result["changes"][2]["sequence"]["groupId"] == "poc"211 assert result["changes"][2]["sequence"]["dependsOn"] == ["phase-one", "phase-two"]212 assert not (changes_root / "final-proof" / ".sequence").exists()213214 context = json.loads(tools.openspec_context({"identifier": "demo"}))215 final = next(item for item in context["changes"] if item["name"] == "final-proof")216 assert final["sequence"]["dependsOn"] == ["phase-one", "phase-two"]217218219# ---------------------------------------------------------------------------220# Idea resolution (ivault/os_fd3445 tasks 1.3): openspec_context must list and221# resolve registered idea references using the same source-qualified shape as222# changes, and must fail explicitly on cross-kind token ambiguity.223# ---------------------------------------------------------------------------224225226def _register_demo_source(tmp_path, monkeypatch):227 """Isolate registry DB to tmp and point tools at the real registry module."""228 monkeypatch.setattr(registry, "db_path", lambda: tmp_path / "openspec.db")229 monkeypatch.setattr(tools, "_registry_module", lambda: registry)230 registry.add_source(str(tmp_path), "demo")231232233def _write_idea(tmp_path, stem: str, body: str = "Idea body text"):234 ideas_root = tmp_path / "openspec" / "ideas"235 ideas_root.mkdir(parents=True, exist_ok=True)236 idea_file = ideas_root / f"{stem}.md"237 idea_file.write_text(f"---\ntitle: {stem.title()}\n---\n{body}\n", encoding="utf-8")238 return idea_file239240241def test_openspec_context_lists_ideas_for_bare_source(tmp_path, monkeypatch):242 _register_demo_source(tmp_path, monkeypatch)243 _write_idea(tmp_path, "hermes-agentmail-inbox-integration")244245 result = json.loads(tools.openspec_context({"identifier": "demo"}))246247 assert result["ok"] is True248 ideas = result["ideas"]249 assert len(ideas) == 1250 idea = ideas[0]251 assert idea["name"] == "hermes-agentmail-inbox-integration"252 assert idea["filename"] == "hermes-agentmail-inbox-integration.md"253 assert idea["token"] == "os_1655dd"254255256def test_openspec_context_resolves_idea_by_token(tmp_path, monkeypatch):257 _register_demo_source(tmp_path, monkeypatch)258 _write_idea(tmp_path, "hermes-agentmail-inbox-integration")259260 result = json.loads(tools.openspec_context({"identifier": "demo/os_1655dd"}))261262 assert result["ok"] is True263 idea = result["idea"]264 assert idea["name"] == "hermes-agentmail-inbox-integration"265 assert idea["filename"] == "hermes-agentmail-inbox-integration.md"266 assert idea["token"] == "os_1655dd"267 assert "Idea body text" in idea["content"]268269270def test_openspec_context_resolves_idea_by_literal_stem(tmp_path, monkeypatch):271 _register_demo_source(tmp_path, monkeypatch)272 _write_idea(tmp_path, "hermes-agentmail-inbox-integration")273274 result = json.loads(275 tools.openspec_context({"identifier": "demo/hermes-agentmail-inbox-integration"})276 )277278 assert result["ok"] is True279 idea = result["idea"]280 assert idea["name"] == "hermes-agentmail-inbox-integration"281 assert idea["filename"] == "hermes-agentmail-inbox-integration.md"282 assert idea["token"] == "os_1655dd"283284285def test_openspec_context_reports_cross_kind_ambiguity(tmp_path, monkeypatch):286 _register_demo_source(tmp_path, monkeypatch)287 # Change folder and idea file share the same stem → identical derived token.288 change_dir = tmp_path / "openspec" / "changes" / "same-artifact"289 change_dir.mkdir(parents=True)290 (change_dir / "proposal.md").write_text("# Same Artifact\n", encoding="utf-8")291 _write_idea(tmp_path, "same-artifact")292 token = registry.change_token("same-artifact")293294 result = json.loads(tools.openspec_context({"identifier": f"demo/{token}"}))295296 assert result["ok"] is False297 error = result["error"].lower()298 assert "ambiguous" in error299 for kind in ("change", "spec", "idea"):300 assert kind in error