Three new filesystem-backed agent tools for spec lifecycle: - openspec_spec_create: structured input (title, purpose, requirements with scenarios) → writes properly formatted spec.md. change param scopes to change delta specs; omit for baseline. force flag for overwrite. - openspec_spec_show: reads a spec as structured JSON using spec_parser.parse_spec. Works on both change and baseline specs. - openspec_spec_list: lists specs within a change or baseline specs. Added spec_to_markdown serializer to spec_parser.py — inverse of parse_spec, enables round-trip: create → show → edit → create(force). All three tools are filesystem-backed (no CLI binary needed), same as idea/change tools. 21 new tests (52 total pass). OpenSpec validate --strict: 6/6 pass. Docs updated to 19 tools.
- commit
- d0b5f969e54f20647c68d637ba5e2858701119b5
- parent
- be08e83798ef9cdd23a44df6c34760a9b566889a
- author
- dami etoile <dami.etoile@gmail.com>
| M | README.md | 6 | +- |
| M | __init__.py | 3 | + |
| M | docs/ | 8 | +- |
| M | openspec/ | 28 | +- |
| M | plugin.yaml | 3 | + |
| M | schemas.py | 114 | +++++++ |
| M | spec_parser.py | 42 | +++ |
| A | tests/ | 485 | ++++++++++++++++++++++++++++++ |
| M | tools.py | 174 | +++++++++++ |
@@ -13,7 +13,7 @@ This plugin closes both gaps:
1313 1414 ## What you get1515 16-**Agent tools** — 16 tools covering the full OpenSpec lifecycle: idea capture and enrichment, change creation/promotion/archival, task tracking, spec validation, semantic spec diffing, and context resolution.16+**Agent tools** — 19 tools covering the full OpenSpec lifecycle: idea capture and enrichment, change creation/promotion/archival, task tracking, spec validation, semantic spec diffing, spec create/show/list, and context resolution.1717 1818 | Group | Tools |1919 |---|---|@@ -21,9 +21,9 @@ This plugin closes both gaps:
2121 | Ideas | `openspec_idea_create`, `openspec_idea_enrich`, `openspec_idea_promote` |2222 | Changes | `openspec_change_create`, `openspec_change_promote`, `openspec_change_archive`, `openspec_change_unarchive` |2323 | Tasks | `openspec_task_list`, `openspec_task_set_status` |24-| Specs | `openspec_spec_diff` |24+| Specs | `openspec_spec_diff`, `openspec_spec_create`, `openspec_spec_show`, `openspec_spec_list` |2525 26-`openspec_context`, `openspec_spec_diff`, and the lifecycle write tools (idea/change/task) are filesystem-backed and always available. `openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, and `openspec_instructions` require the `openspec` CLI binary.26+`openspec_context`, `openspec_spec_diff`, `openspec_spec_create`, `openspec_spec_show`, `openspec_spec_list`, and the lifecycle write tools (idea/change/task) are filesystem-backed and always available. `openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, and `openspec_instructions` require the `openspec` CLI binary.2727 2828 For the full tool reference, lifecycle states, and delegation patterns, see [docs/index.md](docs/index.md).2929 @@ -28,6 +28,9 @@ _WRITE_TOOLS = (
2828 ("openspec_change_archive", schemas.OPENSPEC_CHANGE_ARCHIVE, tools.openspec_change_archive, "📦"),2929 ("openspec_change_unarchive", schemas.OPENSPEC_CHANGE_UNARCHIVE, tools.openspec_change_unarchive, "♻️"),3030 ("openspec_spec_diff", schemas.OPENSPEC_SPEC_DIFF, tools.openspec_spec_diff, "🔍"),31+ ("openspec_spec_create", schemas.OPENSPEC_SPEC_CREATE, tools.openspec_spec_create, "✏️"),32+ ("openspec_spec_show", schemas.OPENSPEC_SPEC_SHOW, tools.openspec_spec_show, "📄"),33+ ("openspec_spec_list", schemas.OPENSPEC_SPEC_LIST, tools.openspec_spec_list, "📋"),3134 )3235 3336 @@ -1,6 +1,6 @@
11 # Tool Reference22 3-Two perspectives on the same 16 tools. Use whichever table matches what you3+Two perspectives on the same 19 tools. Use whichever table matches what you44 already know.55 66 ## Artifact → tool mapping@@ -28,6 +28,9 @@ that performs it.
2828 | Archive a completed change | `openspec/changes/archive/` | `openspec_change_archive` | Moves the change dir to archive |2929 | Reopen an archived change | `openspec/changes/` (restore) | `openspec_change_unarchive` | Moves the change dir back to active |3030 | Compare a change spec against its baseline | Spec files (read-only) | `openspec_spec_diff` | Returns a structured semantic delta at the requirement/scenario level plus a unified line diff fallback. Filesystem-backed. |31+| Create a spec from structured input | `openspec/specs/<spec>/spec.md` or `openspec/changes/<change>/specs/<spec>/spec.md` | `openspec_spec_create` | Writes formatted spec.md from title/purpose/requirements array. Overwrite with `force`. Filesystem-backed. |32+| Read a spec as structured JSON | Spec files (read-only) | `openspec_spec_show` | Returns parsed title, purpose, requirements with scenarios and steps. Filesystem-backed. |33+| List specs in a project | `openspec/specs/` or `openspec/changes/<change>/specs/` | `openspec_spec_list` | Returns array of spec names. Change-scoped or baseline. Filesystem-backed. |3134 3235 ---3336 @@ -49,6 +52,9 @@ the reference.
4952 | `openspec_change_archive` | Moves `openspec/changes/<change>/` → `openspec/changes/archive/<change>/` | Done → Archived |5053 | `openspec_change_unarchive` | Moves archive dir back to active changes | Archived → Done |5154 | `openspec_spec_diff` | Returns structured semantic delta (requirement/scenario level) between change spec and baseline, or worktree spec and HEAD. Read-only. | (no transition — read tool) |55+| `openspec_spec_create` | Writes formatted spec.md from structured input (title, purpose, requirements with scenarios). `change` scopes to delta specs; omit for baseline. | (creates spec file) |56+| `openspec_spec_show` | Reads and parses a spec into structured JSON (title, purpose, requirements, scenarios, steps). Read-only. | (no transition — read tool) |57+| `openspec_spec_list` | Lists spec names under `openspec/specs/` or `openspec/changes/<change>/specs/`. Read-only. | (no transition — read tool) |5258 5359 ### Read tools5460 @@ -1,16 +1,16 @@
11 ## 1. Tasks22 3-- [ ] 1.1 1.1 Add `openspec_spec_create` schema to schemas.py (structured input: title, purpose, requirements array with name/description/scenarios)4-- [ ] 1.2 1.2 Add `openspec_spec_show` schema (inputs: spec name, optional change id, optional workdir)5-- [ ] 1.3 1.3 Add `openspec_spec_list` schema (inputs: optional change id, optional workdir)6-- [ ] 1.4 1.4 Register all three tools in __init__.py and plugin.yaml7-- [ ] 1.5 2.1 Add `spec_to_markdown` serializer to spec_parser.py (inverse of parse_spec — structured input → formatted spec.md)8-- [ ] 1.6 2.2 Implement `openspec_spec_create` handler in tools.py (slug validation, change-scoped vs baseline, overwrite protection)9-- [ ] 1.7 2.3 Implement `openspec_spec_show` handler in tools.py (uses parse_spec, returns structured JSON)10-- [ ] 1.8 2.4 Implement `openspec_spec_list` handler in tools.py (directory walk: change specs or baseline specs)11-- [ ] 1.9 3.1 Add tests for spec_create (structured input → valid markdown, overwrite refusal, invalid slug)12-- [ ] 1.10 3.2 Add tests for spec_show (parse round-trip, missing spec, change vs baseline)13-- [ ] 1.11 3.3 Add tests for spec_list (change-scoped, baseline, empty)14-- [ ] 1.12 4.1 Update agent-tools spec with new requirements for create/show/list15-- [ ] 1.13 4.2 Update README and docs/tool-reference.md tool counts and tables16-- [ ] 1.14 4.3 Run full test suite and openspec validate --strict3+- [x] 1.1 1.1 Add `openspec_spec_create` schema to schemas.py (structured input: title, purpose, requirements array with name/description/scenarios)4+- [x] 1.2 1.2 Add `openspec_spec_show` schema (inputs: spec name, optional change id, optional workdir)5+- [x] 1.3 1.3 Add `openspec_spec_list` schema (inputs: optional change id, optional workdir)6+- [x] 1.4 1.4 Register all three tools in __init__.py and plugin.yaml7+- [x] 1.5 2.1 Add `spec_to_markdown` serializer to spec_parser.py (inverse of parse_spec — structured input → formatted spec.md)8+- [x] 1.6 2.2 Implement `openspec_spec_create` handler in tools.py (slug validation, change-scoped vs baseline, overwrite protection)9+- [x] 1.7 2.3 Implement `openspec_spec_show` handler in tools.py (uses parse_spec, returns structured JSON)10+- [x] 1.8 2.4 Implement `openspec_spec_list` handler in tools.py (directory walk: change specs or baseline specs)11+- [x] 1.9 3.1 Add tests for spec_create (structured input → valid markdown, overwrite refusal, invalid slug)12+- [x] 1.10 3.2 Add tests for spec_show (parse round-trip, missing spec, change vs baseline)13+- [x] 1.11 3.3 Add tests for spec_list (change-scoped, baseline, empty)14+- [x] 1.12 4.1 Update agent-tools spec with new requirements for create/show/list15+- [x] 1.13 4.2 Update README and docs/tool-reference.md tool counts and tables16+- [x] 1.14 4.3 Run full test suite and openspec validate --strict@@ -323,3 +323,117 @@ OPENSPEC_SPEC_DIFF = {
323323 "required": ["spec"],324324 },325325 }326+327+OPENSPEC_SPEC_CREATE = {328+ "name": "openspec_spec_create",329+ "description": (330+ "Create a spec from structured input (title, purpose, requirements with "331+ "scenarios) and write a properly formatted spec.md. Filesystem-backed — "332+ "does not require the OpenSpec CLI binary. Pass 'change' to write a "333+ "change-scoped delta spec under openspec/changes/<change>/specs/; omit to "334+ "write a baseline spec under openspec/specs/. Use 'force' to overwrite an "335+ "existing spec. The requirements structure matches what openspec_spec_show "336+ "returns, enabling round-trip edit workflows."337+ ),338+ "parameters": {339+ "type": "object",340+ "properties": {341+ **PROJECT_PROPS,342+ "spec": {343+ "type": "string",344+ "description": "Spec name (kebab-case, e.g. 'agent-tools'). Used as the directory name.",345+ },346+ "change": {347+ "type": "string",348+ "description": "Change id — when provided, writes under openspec/changes/<change>/specs/<spec>/. Omit for baseline openspec/specs/<spec>/.",349+ },350+ "title": {351+ "type": "string",352+ "description": "Spec title (used in the '# Title' header).",353+ },354+ "purpose": {355+ "type": "string",356+ "description": "Spec purpose statement (used in the '## Purpose' section).",357+ },358+ "requirements": {359+ "type": "array",360+ "description": "Array of requirements. Each: {name, description, scenarios: [{name, steps: [{type, text}]}]}. 'type' is GIVEN/WHEN/THEN/AND/BUT.",361+ "items": {362+ "type": "object",363+ "properties": {364+ "name": {"type": "string"},365+ "description": {"type": "string"},366+ "scenarios": {367+ "type": "array",368+ "items": {369+ "type": "object",370+ "properties": {371+ "name": {"type": "string"},372+ "steps": {373+ "type": "array",374+ "items": {375+ "type": "object",376+ "properties": {377+ "type": {"type": "string", "description": "GIVEN, WHEN, THEN, AND, or BUT"},378+ "text": {"type": "string"},379+ },380+ },381+ },382+ },383+ },384+ },385+ },386+ },387+ },388+ "force": {389+ "type": "boolean",390+ "description": "Overwrite an existing spec at the target path. Defaults to false.",391+ },392+ },393+ "required": ["spec", "title", "purpose", "requirements"],394+ },395+}396+397+OPENSPEC_SPEC_SHOW = {398+ "name": "openspec_spec_show",399+ "description": (400+ "Read a spec as structured JSON (title, purpose, requirements with "401+ "scenarios and steps). Filesystem-backed — does not require the OpenSpec "402+ "CLI binary. Pass 'change' to read a change-scoped delta spec; omit to "403+ "read a baseline spec."404+ ),405+ "parameters": {406+ "type": "object",407+ "properties": {408+ **PROJECT_PROPS,409+ "spec": {410+ "type": "string",411+ "description": "Spec name (e.g. 'agent-tools') or relative path.",412+ },413+ "change": {414+ "type": "string",415+ "description": "Change id — when provided, reads from openspec/changes/<change>/specs/. Omit for baseline openspec/specs/.",416+ },417+ },418+ "required": ["spec"],419+ },420+}421+422+OPENSPEC_SPEC_LIST = {423+ "name": "openspec_spec_list",424+ "description": (425+ "List specs in a project. Filesystem-backed — does not require the "426+ "OpenSpec CLI binary. Pass 'change' to list delta specs within a change; "427+ "omit to list baseline specs."428+ ),429+ "parameters": {430+ "type": "object",431+ "properties": {432+ **PROJECT_PROPS,433+ "change": {434+ "type": "string",435+ "description": "Change id — when provided, lists specs under openspec/changes/<change>/specs/. Omit for baseline openspec/specs/.",436+ },437+ },438+ },439+}@@ -240,6 +240,48 @@ def unified_diff(before_md: str | None, after_md: str | None, path: str = "spec"
240240 )241241 242242 243+def spec_to_markdown(title: str, purpose: str, requirements: list[dict[str, Any]]) -> str:244+ """Serialize structured spec data into OpenSpec spec markdown.245+246+ Inverse of ``parse_spec`` — takes ``{title, purpose, requirements}`` and247+ produces ``# Title`` / ``## Purpose`` / ``## Requirements`` /248+ ``### Requirement:`` / ``#### Scenario:`` formatted markdown.249+250+ Each requirement is ``{name, description, scenarios}`` and each251+ scenario is ``{name, steps: [{type, text}]}``.252+ """253+ lines: list[str] = [f"# {title}", ""]254+255+ if purpose:256+ lines.append("## Purpose")257+ lines.append("")258+ lines.append(purpose)259+ lines.append("")260+261+ if requirements:262+ lines.append("## Requirements")263+ lines.append("")264+ for req in requirements:265+ name = str(req.get("name") or "").strip()266+ desc = str(req.get("description") or "").strip()267+ lines.append(f"### Requirement: {name}")268+ if desc:269+ lines.append(desc)270+ lines.append("")271+ for scn in req.get("scenarios") or []:272+ scn_name = str(scn.get("name") or "").strip()273+ lines.append(f"#### Scenario: {scn_name}")274+ lines.append("")275+ for step in scn.get("steps") or []:276+ step_type = str(step.get("type") or "THEN").strip().upper()277+ step_text = str(step.get("text") or "").strip()278+ lines.append(f"- **{step_type}** {step_text}")279+ lines.append("")280+281+ # Trim trailing blank lines, ensure single trailing newline282+ return "\n".join(lines).rstrip() + "\n"283+284+243285 def re_match(pattern: str, string: str):244286 """Wrapper to avoid importing re at module level in callers."""245287 import re@@ -0,0 +1,485 @@
1+"""Tests for spec-first-class tools: create, show, list, and serializer round-trip."""2+3+import json4+import sys5+from pathlib import Path6+7+import pytest8+9+# Ensure the repo root is importable10+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))11+12+import tools13+import spec_parser14+15+16+# ---------------------------------------------------------------------------17+# spec_to_markdown serializer18+# ---------------------------------------------------------------------------19+20+def test_serializer_produces_valid_spec_markdown():21+ requirements = [22+ {23+ "name": "Create spec",24+ "description": "The system SHALL create a spec.",25+ "scenarios": [26+ {27+ "name": "Happy path",28+ "steps": [29+ {"type": "GIVEN", "text": "a project"},30+ {"type": "WHEN", "text": "an agent creates a spec"},31+ {"type": "THEN", "text": "the spec is written"},32+ ],33+ },34+ ],35+ },36+ ]37+ md = spec_parser.spec_to_markdown("Test Spec", "Test purpose", requirements)38+ assert "# Test Spec" in md39+ assert "## Purpose" in md40+ assert "Test purpose" in md41+ assert "## Requirements" in md42+ assert "### Requirement: Create spec" in md43+ assert "The system SHALL create a spec." in md44+ assert "#### Scenario: Happy path" in md45+ assert "- **GIVEN** a project" in md46+ assert "- **WHEN** an agent creates a spec" in md47+ assert "- **THEN** the spec is written" in md48+49+50+def test_serializer_round_trip_with_parse_spec():51+ """spec_to_markdown output must be parseable by parse_spec back to equivalent structure."""52+ original = [53+ {54+ "name": "Round trip",55+ "description": "Description here.",56+ "scenarios": [57+ {58+ "name": "Scenario A",59+ "steps": [60+ {"type": "GIVEN", "text": "context"},61+ {"type": "WHEN", "text": "action"},62+ {"type": "THEN", "text": "result"},63+ ],64+ },65+ ],66+ },67+ ]68+ md = spec_parser.spec_to_markdown("Round Trip Spec", "Purpose statement", original)69+ parsed = spec_parser.parse_spec(md)70+71+ assert parsed["title"] == "Round Trip Spec"72+ assert parsed["purpose"] == "Purpose statement"73+ assert len(parsed["requirements"]) == 174+ req = parsed["requirements"][0]75+ assert req["name"] == "Round trip"76+ assert req["description"] == "Description here."77+ assert len(req["scenarios"]) == 178+ scn = req["scenarios"][0]79+ assert scn["name"] == "Scenario A"80+ assert len(scn["steps"]) == 381+ assert scn["steps"][0]["type"] == "GIVEN"82+ assert scn["steps"][0]["text"] == "context"83+84+85+def test_serializer_handles_empty_requirements():86+ md = spec_parser.spec_to_markdown("Empty", "No reqs", [])87+ assert "# Empty" in md88+ assert "## Purpose" in md89+ assert "## Requirements" not in md90+91+92+def test_serializer_handles_no_purpose():93+ md = spec_parser.spec_to_markdown("No Purpose", "", [{"name": "R", "description": "D", "scenarios": []}])94+ assert "# No Purpose" in md95+ assert "## Purpose" not in md96+ assert "### Requirement: R" in md97+98+99+# ---------------------------------------------------------------------------100+# openspec_spec_create101+# ---------------------------------------------------------------------------102+103+def _make_project(tmp_path: Path) -> Path:104+ """Create a minimal openspec project structure."""105+ root = tmp_path / "project"106+ (root / "openspec" / "specs").mkdir(parents=True)107+ (root / "openspec" / "changes").mkdir(parents=True)108+ return root109+110+111+def _sample_requirements():112+ return [113+ {114+ "name": "Create spec",115+ "description": "The system SHALL create a spec.",116+ "scenarios": [117+ {118+ "name": "Happy path",119+ "steps": [120+ {"type": "GIVEN", "text": "a project"},121+ {"type": "WHEN", "text": "an agent creates a spec"},122+ {"type": "THEN", "text": "the spec is written"},123+ ],124+ },125+ ],126+ },127+ ]128+129+130+def test_spec_create_writes_baseline_spec(tmp_path):131+ root = _make_project(tmp_path)132+ result = json.loads(tools.openspec_spec_create({133+ "workdir": str(root),134+ "spec": "my-spec",135+ "title": "My Spec",136+ "purpose": "Test purpose",137+ "requirements": _sample_requirements(),138+ }))139+ assert result["ok"] is True140+ assert result["spec"] == "my-spec"141+ assert result["change"] is None142+ assert result["path"] == "openspec/specs/my-spec/spec.md"143+ spec_file = root / "openspec" / "specs" / "my-spec" / "spec.md"144+ assert spec_file.is_file()145+ content = spec_file.read_text()146+ assert "# My Spec" in content147+ assert "### Requirement: Create spec" in content148+149+150+def test_spec_create_writes_change_scoped_spec(tmp_path):151+ root = _make_project(tmp_path)152+ change_dir = root / "openspec" / "changes" / "my-change"153+ change_dir.mkdir(parents=True)154+ (change_dir / "proposal.md").write_text("## Why\nTest")155+156+ result = json.loads(tools.openspec_spec_create({157+ "workdir": str(root),158+ "spec": "feature-spec",159+ "change": "my-change",160+ "title": "Feature Spec",161+ "purpose": "Feature purpose",162+ "requirements": _sample_requirements(),163+ }))164+ assert result["ok"] is True165+ assert result["change"] == "my-change"166+ assert result["path"] == "openspec/changes/my-change/specs/feature-spec/spec.md"167+ spec_file = change_dir / "specs" / "feature-spec" / "spec.md"168+ assert spec_file.is_file()169+170+171+def test_spec_create_refuses_overwrite(tmp_path):172+ root = _make_project(tmp_path)173+ # First create succeeds174+ r1 = json.loads(tools.openspec_spec_create({175+ "workdir": str(root),176+ "spec": "existing",177+ "title": "First",178+ "purpose": "P",179+ "requirements": _sample_requirements(),180+ }))181+ assert r1["ok"] is True182+183+ # Second create refuses without force184+ r2 = json.loads(tools.openspec_spec_create({185+ "workdir": str(root),186+ "spec": "existing",187+ "title": "Second",188+ "purpose": "P2",189+ "requirements": _sample_requirements(),190+ }))191+ assert r2["ok"] is False192+ assert "already exists" in r2["error"]193+ # Original content preserved194+ content = (root / "openspec" / "specs" / "existing" / "spec.md").read_text()195+ assert "# First" in content196+197+198+def test_spec_create_force_overwrites(tmp_path):199+ root = _make_project(tmp_path)200+ tools.openspec_spec_create({201+ "workdir": str(root),202+ "spec": "overwritable",203+ "title": "Original",204+ "purpose": "P",205+ "requirements": _sample_requirements(),206+ })207+ r2 = json.loads(tools.openspec_spec_create({208+ "workdir": str(root),209+ "spec": "overwritable",210+ "title": "Replaced",211+ "purpose": "P2",212+ "requirements": _sample_requirements(),213+ "force": True,214+ }))215+ assert r2["ok"] is True216+ content = (root / "openspec" / "specs" / "overwritable" / "spec.md").read_text()217+ assert "# Replaced" in content218+219+220+def test_spec_create_invalid_slug(tmp_path):221+ root = _make_project(tmp_path)222+ for bad in ["../escape", "UPPER CASE", "has_underscore", "with/slash"]:223+ r = json.loads(tools.openspec_spec_create({224+ "workdir": str(root),225+ "spec": bad,226+ "title": "T",227+ "purpose": "P",228+ "requirements": _sample_requirements(),229+ }))230+ assert r["ok"] is False, f"expected failure for slug: {bad}"231+ assert "kebab-case" in r["error"] or "parent references" in r["error"]232+233+234+def test_spec_create_missing_title(tmp_path):235+ root = _make_project(tmp_path)236+ r = json.loads(tools.openspec_spec_create({237+ "workdir": str(root),238+ "spec": "test",239+ "purpose": "P",240+ "requirements": _sample_requirements(),241+ }))242+ assert r["ok"] is False243+ assert "title" in r["error"]244+245+246+def test_spec_create_missing_purpose(tmp_path):247+ root = _make_project(tmp_path)248+ r = json.loads(tools.openspec_spec_create({249+ "workdir": str(root),250+ "spec": "test",251+ "title": "T",252+ "requirements": _sample_requirements(),253+ }))254+ assert r["ok"] is False255+ assert "purpose" in r["error"]256+257+258+def test_spec_create_empty_requirements(tmp_path):259+ root = _make_project(tmp_path)260+ r = json.loads(tools.openspec_spec_create({261+ "workdir": str(root),262+ "spec": "test",263+ "title": "T",264+ "purpose": "P",265+ "requirements": [],266+ }))267+ assert r["ok"] is False268+ assert "requirements" in r["error"]269+270+271+# ---------------------------------------------------------------------------272+# openspec_spec_show273+# ---------------------------------------------------------------------------274+275+def test_spec_show_returns_structured_json(tmp_path):276+ root = _make_project(tmp_path)277+ # Create a spec first278+ tools.openspec_spec_create({279+ "workdir": str(root),280+ "spec": "showable",281+ "title": "Showable Spec",282+ "purpose": "Show purpose",283+ "requirements": _sample_requirements(),284+ })285+286+ result = json.loads(tools.openspec_spec_show({287+ "workdir": str(root),288+ "spec": "showable",289+ }))290+ assert result["ok"] is True291+ assert result["title"] == "Showable Spec"292+ assert result["purpose"] == "Show purpose"293+ assert len(result["requirements"]) == 1294+ req = result["requirements"][0]295+ assert req["name"] == "Create spec"296+ assert len(req["scenarios"]) == 1297+ assert req["scenarios"][0]["name"] == "Happy path"298+ assert len(req["scenarios"][0]["steps"]) == 3299+300+301+def test_spec_show_change_scoped(tmp_path):302+ root = _make_project(tmp_path)303+ change_dir = root / "openspec" / "changes" / "feat"304+ change_dir.mkdir(parents=True)305+ (change_dir / "proposal.md").write_text("## Why\nTest")306+307+ tools.openspec_spec_create({308+ "workdir": str(root),309+ "spec": "delta-spec",310+ "change": "feat",311+ "title": "Delta",312+ "purpose": "Delta purpose",313+ "requirements": _sample_requirements(),314+ })315+316+ result = json.loads(tools.openspec_spec_show({317+ "workdir": str(root),318+ "spec": "delta-spec",319+ "change": "feat",320+ }))321+ assert result["ok"] is True322+ assert result["change"] == "feat"323+ assert result["title"] == "Delta"324+325+326+def test_spec_show_missing_spec(tmp_path):327+ root = _make_project(tmp_path)328+ result = json.loads(tools.openspec_spec_show({329+ "workdir": str(root),330+ "spec": "nonexistent",331+ }))332+ assert result["ok"] is False333+ assert "not found" in result["error"]334+335+336+def test_spec_show_round_trip_with_real_spec(tmp_path):337+ """Create a spec with structured input, then show it — the round-trip must match."""338+ root = _make_project(tmp_path)339+ original_reqs = [340+ {341+ "name": "Round Trip Req",342+ "description": "Description text.",343+ "scenarios": [344+ {345+ "name": "Scenario One",346+ "steps": [347+ {"type": "GIVEN", "text": "a context"},348+ {"type": "WHEN", "text": "an action"},349+ {"type": "THEN", "text": "a result"},350+ ],351+ },352+ ],353+ },354+ ]355+ tools.openspec_spec_create({356+ "workdir": str(root),357+ "spec": "round-trip",358+ "title": "Round Trip Spec",359+ "purpose": "Round trip purpose",360+ "requirements": original_reqs,361+ })362+363+ shown = json.loads(tools.openspec_spec_show({364+ "workdir": str(root),365+ "spec": "round-trip",366+ }))367+ assert shown["ok"] is True368+ assert shown["title"] == "Round Trip Spec"369+ assert shown["purpose"] == "Round trip purpose"370+ req = shown["requirements"][0]371+ assert req["name"] == "Round Trip Req"372+ assert req["description"] == "Description text."373+ scn = req["scenarios"][0]374+ assert scn["name"] == "Scenario One"375+ assert [s["type"] for s in scn["steps"]] == ["GIVEN", "WHEN", "THEN"]376+ assert [s["text"] for s in scn["steps"]] == ["a context", "an action", "a result"]377+378+379+# ---------------------------------------------------------------------------380+# openspec_spec_list381+# ---------------------------------------------------------------------------382+383+def test_spec_list_baseline(tmp_path):384+ root = _make_project(tmp_path)385+ # Create two baseline specs386+ for name in ["alpha-spec", "beta-spec"]:387+ tools.openspec_spec_create({388+ "workdir": str(root),389+ "spec": name,390+ "title": name.title(),391+ "purpose": "P",392+ "requirements": _sample_requirements(),393+ })394+395+ result = json.loads(tools.openspec_spec_list({396+ "workdir": str(root),397+ }))398+ assert result["ok"] is True399+ assert result["change"] is None400+ assert result["count"] == 2401+ assert "alpha-spec" in result["specs"]402+ assert "beta-spec" in result["specs"]403+404+405+def test_spec_list_change_scoped(tmp_path):406+ root = _make_project(tmp_path)407+ change_dir = root / "openspec" / "changes" / "my-change"408+ change_dir.mkdir(parents=True)409+ (change_dir / "proposal.md").write_text("## Why\nTest")410+411+ for name in ["delta-a", "delta-b"]:412+ tools.openspec_spec_create({413+ "workdir": str(root),414+ "spec": name,415+ "change": "my-change",416+ "title": name.title(),417+ "purpose": "P",418+ "requirements": _sample_requirements(),419+ })420+421+ result = json.loads(tools.openspec_spec_list({422+ "workdir": str(root),423+ "change": "my-change",424+ }))425+ assert result["ok"] is True426+ assert result["change"] == "my-change"427+ assert result["count"] == 2428+ assert "delta-a" in result["specs"]429+ assert "delta-b" in result["specs"]430+431+432+def test_spec_list_empty(tmp_path):433+ root = _make_project(tmp_path)434+ result = json.loads(tools.openspec_spec_list({435+ "workdir": str(root),436+ }))437+ assert result["ok"] is True438+ assert result["count"] == 0439+ assert result["specs"] == []440+441+442+def test_spec_list_missing_specs_dir(tmp_path):443+ """Listing should return empty when the specs directory doesn't exist."""444+ root = tmp_path / "bare-project"445+ (root / "openspec").mkdir(parents=True)446+447+ result = json.loads(tools.openspec_spec_list({448+ "workdir": str(root),449+ }))450+ assert result["ok"] is True451+ assert result["count"] == 0452+453+454+# ---------------------------------------------------------------------------455+# Integration: create → show → list456+# ---------------------------------------------------------------------------457+458+def test_full_workflow_create_show_list(tmp_path):459+ """End-to-end: create a spec, show it, list it."""460+ root = _make_project(tmp_path)461+462+ # Create463+ created = json.loads(tools.openspec_spec_create({464+ "workdir": str(root),465+ "spec": "workflow-spec",466+ "title": "Workflow Spec",467+ "purpose": "Workflow purpose",468+ "requirements": _sample_requirements(),469+ }))470+ assert created["ok"] is True471+472+ # Show473+ shown = json.loads(tools.openspec_spec_show({474+ "workdir": str(root),475+ "spec": "workflow-spec",476+ }))477+ assert shown["ok"] is True478+ assert shown["title"] == "Workflow Spec"479+480+ # List481+ listed = json.loads(tools.openspec_spec_list({482+ "workdir": str(root),483+ }))484+ assert listed["ok"] is True485+ assert "workflow-spec" in listed["specs"]@@ -1219,3 +1219,177 @@ def _git_show_spec(root: Path, spec_path: str) -> str | None:
12191219 if proc.returncode != 0:12201220 return None12211221 return proc.stdout1222+1223+1224+def _spec_target_path(root: Path, spec: str, change: str) -> Path:1225+ """Resolve the spec.md path for a given spec name and optional change scope."""1226+ if change:1227+ base = root / "openspec" / "changes" / change / "specs"1228+ else:1229+ base = root / "openspec" / "specs"1230+ return base / spec / "spec.md"1231+1232+1233+def _spec_dir_from_path(spec_path: Path) -> str:1234+ """Extract the spec directory name from a spec.md path."""1235+ return spec_path.parent.name1236+1237+1238+def _list_specs_in_dir(specs_root: Path) -> list[str]:1239+ """List spec names (directory names containing spec.md) under a specs root."""1240+ if not specs_root.is_dir():1241+ return []1242+ names: list[str] = []1243+ for child in sorted(specs_root.iterdir()):1244+ if child.is_dir() and (child / "spec.md").is_file():1245+ names.append(child.name)1246+ elif child.is_file() and child.suffix == ".md" and child.name != "spec.md":1247+ # Flat .md files at the root level1248+ names.append(child.stem)1249+ return names1250+1251+1252+def openspec_spec_create(args: dict, **kwargs) -> str:1253+ """Create a spec from structured input and write a formatted spec.md.1254+1255+ Filesystem-backed — does not require the OpenSpec CLI binary.1256+ """1257+ root, err, _context = _resolve_project(args)1258+ if err or root is None:1259+ return _error(err or "workdir could not be resolved")1260+1261+ spec = str(args.get("spec") or "").strip()1262+ if not spec:1263+ return _error("spec is required (e.g. 'agent-tools')")1264+1265+ # Validate slug: kebab-case, no path traversal1266+ if not _VALID_CHANGE_RE.match(spec) or ".." in Path(spec).parts or spec.startswith("/"):1267+ return _error(f"spec must be kebab-case (letters, numbers, hyphens): {spec}")1268+1269+ title = str(args.get("title") or "").strip()1270+ if not title:1271+ return _error("title is required")1272+1273+ purpose = str(args.get("purpose") or "").strip()1274+ if not purpose:1275+ return _error("purpose is required")1276+1277+ requirements = args.get("requirements")1278+ if not isinstance(requirements, list) or not requirements:1279+ return _error("requirements must be a non-empty array")1280+1281+ change = str(args.get("change") or "").strip()1282+ if change and not _VALID_CHANGE_RE.match(change):1283+ return _error(f"change must be kebab-case: {change}")1284+1285+ force = bool(args.get("force"))1286+1287+ spec_path = _spec_target_path(root, spec, change)1288+1289+ if spec_path.exists() and not force:1290+ rel = _relative(spec_path, root)1291+ return _error(1292+ f"spec already exists at {rel}. Pass force=true to overwrite.",1293+ spec=spec,1294+ change=change or None,1295+ path=rel,1296+ )1297+1298+ try:1299+ from . import spec_parser # type: ignore1300+ except ImportError:1301+ import spec_parser # type: ignore1302+1303+ md = spec_parser.spec_to_markdown(title, purpose, requirements)1304+1305+ spec_path.parent.mkdir(parents=True, exist_ok=True)1306+ spec_path.write_text(md, encoding="utf-8")1307+1308+ return json.dumps({1309+ "ok": True,1310+ "spec": spec,1311+ "change": change or None,1312+ "path": _relative(spec_path, root),1313+ "absolute_path": str(spec_path),1314+ "workdir": str(root),1315+ })1316+1317+1318+def openspec_spec_show(args: dict, **kwargs) -> str:1319+ """Read a spec as structured JSON using parse_spec.1320+1321+ Filesystem-backed — does not require the OpenSpec CLI binary.1322+ """1323+ root, err, _context = _resolve_project(args)1324+ if err or root is None:1325+ return _error(err or "workdir could not be resolved")1326+1327+ spec = str(args.get("spec") or "").strip()1328+ if not spec:1329+ return _error("spec is required (e.g. 'agent-tools')")1330+1331+ if spec.startswith("/") or ".." in Path(spec).parts:1332+ return _error("spec must be a relative path without parent references")1333+1334+ change = str(args.get("change") or "").strip()1335+1336+ spec_path = _spec_target_path(root, spec, change)1337+ if not spec_path.is_file():1338+ # Try flat .md path1339+ if change:1340+ flat = root / "openspec" / "changes" / change / "specs" / f"{spec}.md"1341+ else:1342+ flat = root / "openspec" / "specs" / f"{spec}.md"1343+ if flat.is_file():1344+ spec_path = flat1345+ else:1346+ location = f"openspec/changes/{change}/specs/{spec}/spec.md" if change else f"openspec/specs/{spec}/spec.md"1347+ return _error(f"spec not found: {location}", spec=spec, change=change or None)1348+1349+ try:1350+ from . import spec_parser # type: ignore1351+ except ImportError:1352+ import spec_parser # type: ignore1353+1354+ md = _read_doc(spec_path)1355+ parsed = spec_parser.parse_spec(md)1356+1357+ return json.dumps({1358+ "ok": True,1359+ "spec": spec,1360+ "change": change or None,1361+ "path": _relative(spec_path, root),1362+ "title": parsed["title"],1363+ "purpose": parsed["purpose"],1364+ "requirements": parsed["requirements"],1365+ "workdir": str(root),1366+ })1367+1368+1369+def openspec_spec_list(args: dict, **kwargs) -> str:1370+ """List specs within a change, or baseline specs.1371+1372+ Filesystem-backed — does not require the OpenSpec CLI binary.1373+ """1374+ root, err, _context = _resolve_project(args)1375+ if err or root is None:1376+ return _error(err or "workdir could not be resolved")1377+1378+ change = str(args.get("change") or "").strip()1379+1380+ if change:1381+ if not _VALID_CHANGE_RE.match(change):1382+ return _error(f"change must be kebab-case: {change}")1383+ specs_root = root / "openspec" / "changes" / change / "specs"1384+ else:1385+ specs_root = root / "openspec" / "specs"1386+1387+ names = _list_specs_in_dir(specs_root)1388+1389+ return json.dumps({1390+ "ok": True,1391+ "change": change or None,1392+ "specs": names,1393+ "count": len(names),1394+ "workdir": str(root),1395+ })