diff --git a/src/engine/spec_adapter.py b/src/engine/spec_adapter.py index edca921..6e98a64 100644 --- a/src/engine/spec_adapter.py +++ b/src/engine/spec_adapter.py @@ -6,6 +6,9 @@ - Rich billing spec: { project: {...}, llms: {...}, agents: {...} } - Legacy format: { meta: {...}, requirements: [...], tech_stack: {...} } - Documentation format: Directory with MASTER_DOCUMENT.md, tech_stack/, user_stories/, etc. +- Spec-kit format: Feature directory with spec.md, plan.md, tasks.md + (github/spec-kit artifact conventions, adapted as a data format only — + execution stays with the engine pipeline) - Markdown requirements (future) """ @@ -26,6 +29,7 @@ class SpecFormat(Enum): RICH_BILLING = "rich_billing" # { project: {...}, llms: {...}, agents: {...} } LEGACY_TECHSTACK = "legacy_techstack" # { meta: {...}, requirements: [...], tech_stack: {...} } SIMPLE = "simple" # { requirements: [...] } + SPEC_KIT = "spec_kit" # Directory with spec.md, plan.md, tasks.md # Lazy import to avoid circular dependency _documentation_loader = None @@ -84,6 +88,35 @@ def to_dict(self) -> Dict: } +@dataclass +class SpecKitTask: + """A single task parsed from a spec-kit tasks.md line. + + Line format: `- [ ] T001 [P] [US1] Description with exact file path`, + where `[P]` marks parallel-safe tasks and `[USn]` links the user story. + """ + task_id: str + description: str + parallel: bool = False + story: Optional[str] = None + phase: str = "" + phase_index: int = 0 + file_paths: List[str] = field(default_factory=list) + completed: bool = False + + def to_dict(self) -> Dict: + return { + "task_id": self.task_id, + "description": self.description, + "parallel": self.parallel, + "story": self.story, + "phase": self.phase, + "phase_index": self.phase_index, + "file_paths": self.file_paths, + "completed": self.completed, + } + + @dataclass class ContextLayers: """Optional rich context extracted from detailed specs.""" @@ -100,6 +133,8 @@ class ContextLayers: epics: List[Dict] = field(default_factory=list) # Epic definitions design_tokens: Dict = field(default_factory=dict) # UI design tokens features: List[Dict] = field(default_factory=list) # Feature breakdowns + # Spec-kit format extension + tasks: List[Dict] = field(default_factory=list) # Parsed tasks.md entries def to_dict(self) -> Dict: return { @@ -115,6 +150,7 @@ def to_dict(self) -> Dict: "epics": self.epics, "design_tokens": self.design_tokens, "features": self.features, + "tasks": self.tasks, } def has_content(self) -> bool: @@ -123,7 +159,7 @@ def has_content(self) -> bool: self.api_specs or self.db_schema or self.llm_config or self.agent_defs or self.workflows or self.frontend_specs or self.monitoring_config or self.diagrams or self.entities or - self.epics or self.design_tokens or self.features + self.epics or self.design_tokens or self.features or self.tasks ) def get_diagrams_by_type(self, diagram_type: str) -> List[Dict]: @@ -193,6 +229,7 @@ class SpecAdapter: - Rich billing: { project: {...}, llms: {...}, agents: {...} } - Legacy: { meta: {...}, requirements: [...], tech_stack: {...} } - Documentation: Directory with MASTER_DOCUMENT.md, tech_stack/, etc. + - Spec-kit: Directory with spec.md, plan.md, tasks.md """ def __init__(self): @@ -217,6 +254,10 @@ def load(self, path: Union[str, Path]) -> NormalizedSpec: self.last_format = SpecFormat.DOCUMENTATION logger.info("spec_format_detected", format="documentation", source=str(path)) return self._normalize_documentation(path) + elif self._is_speckit_project(path): + self.last_format = SpecFormat.SPEC_KIT + logger.info("spec_format_detected", format="spec_kit", source=str(path)) + return self._normalize_speckit(path) else: # Try to find a spec file in the directory for spec_file in ["requirements.json", "spec.json", "project.json"]: @@ -245,6 +286,16 @@ def _is_documentation_project(self, path: Path) -> bool: ] return any(indicator.exists() for indicator in indicators) + def _is_speckit_project(self, path: Path) -> bool: + """Check if path is a spec-kit feature directory (spec.md + tasks.md). + + plan.md is deliberately not part of detection: a feature directory + without plan.md is still recognized as spec-kit and then rejected + fail-closed in _normalize_speckit with a clear error, instead of + silently falling through to another format. + """ + return (path / "spec.md").exists() and (path / "tasks.md").exists() + def normalize(self, raw_spec: Dict, source_path: str = "") -> NormalizedSpec: """Normalize any spec format to internal format.""" @@ -681,6 +732,243 @@ def _normalize_documentation(self, project_path: Path) -> NormalizedSpec: }, ) + # ========================================================================= + # SPEC-KIT FORMAT NORMALIZER + # ========================================================================= + + # `- [ ] T001 [P] [US1] Description ...` (checkbox, id, optional flags) + _SPECKIT_TASK_RE = re.compile( + r"^-\s+\[(?P[ xX])\]\s+(?PT\d+)" + r"(?:\s+\[(?PP)\])?" + r"(?:\s+\[(?PUS\d+)\])?" + r"\s+(?P.+)$" + ) + _SPECKIT_STORY_RE = re.compile( + r"^###\s+User Story (?P\d+)\s*-\s*(?P.+?)" + r"(?:\s*\(Priority:\s*(?P<prio>P\d+)\))?\s*$" + ) + _SPECKIT_FR_RE = re.compile(r"^-\s+\*\*(?P<id>FR-\d+)\*\*:\s*(?P<text>.+)$") + # File paths mentioned in task descriptions: `src/api/notes.py` or bare + # well-known filenames like `pyproject.toml`. + _SPECKIT_PATH_RE = re.compile( + r"(?:[\w.-]+/)+[\w.-]+\.[A-Za-z0-9_]+" + r"|\b[\w-]+\.(?:py|sql|toml|yaml|yml|json|md|ts|tsx|js|jsx|css|html|txt|ini|cfg)\b" + ) + _SPECKIT_STORY_PRIORITY = {"P1": "high", "P2": "medium"} # P3+ -> low + + def _normalize_speckit(self, project_path: Path) -> NormalizedSpec: + """Normalize a spec-kit feature directory {spec.md, plan.md, tasks.md}. + + Mapping (spec-kit artifact -> internal format): + - spec.md user stories -> Requirement (req_id USn, priority from Pn) + - spec.md FR-xxx list -> Requirement (req_id FR-xxx) + - plan.md tech context -> tech_stack (full context kept verbatim + under tech_stack["technical_context"]) + - tasks.md task lines -> context_layers.tasks (SpecKitTask dicts); + speckit_tasks_to_slices() maps them to + TaskSlice for the slicer/planning chain + + Fail-closed: missing plan.md raises FileNotFoundError; a tasks.md + without any task lines or with duplicate task ids raises ValueError. + """ + plan_path = project_path / "plan.md" + if not plan_path.exists(): + raise FileNotFoundError( + f"spec-kit feature directory {project_path} has no plan.md. " + "A spec-kit job spec requires spec.md, plan.md and tasks.md; " + "run the planning step before submitting the job." + ) + + spec_text = (project_path / "spec.md").read_text(encoding="utf-8") + plan_text = plan_path.read_text(encoding="utf-8") + tasks_text = (project_path / "tasks.md").read_text(encoding="utf-8") + + project_name = self._speckit_title(spec_text) + requirements = self._parse_speckit_requirements(spec_text) + tech_context = self._parse_speckit_tech_context(plan_text) + tasks = self._parse_speckit_tasks(tasks_text) + + tech_stack = { + "id": "spec_kit_stack", + "name": project_name, + "backend": { + "language": tech_context.get("Language/Version", ""), + "framework": tech_context.get("Primary Dependencies", ""), + }, + "database": {"type": tech_context.get("Storage", "")}, + "testing": {"framework": tech_context.get("Testing", "")}, + "deployment": {"platform": tech_context.get("Target Platform", "")}, + "project_type": tech_context.get("Project Type", ""), + "technical_context": tech_context, + } + + logger.info( + "speckit_normalized", + requirements=len(requirements), + tasks=len(tasks), + path=str(project_path), + ) + + return NormalizedSpec( + project_name=project_name, + project_description=self._speckit_summary(plan_text), + requirements=requirements, + tech_stack=tech_stack, + context_layers=ContextLayers(tasks=[t.to_dict() for t in tasks]), + raw_spec={ + "format": "spec_kit", + "path": str(project_path), + "story_count": sum( + 1 for r in requirements if r.source == "spec.md:user_story" + ), + "task_count": len(tasks), + }, + ) + + def _speckit_title(self, spec_text: str) -> str: + """Project name from the spec.md h1 title.""" + for line in spec_text.splitlines(): + match = re.match(r"^#\s+(?:Feature Specification:\s*)?(.+?)\s*$", line) + if match: + return match.group(1) + return "Unnamed Feature" + + def _speckit_summary(self, plan_text: str) -> str: + """First paragraph of the plan.md Summary section, if present.""" + lines = plan_text.splitlines() + in_summary = False + collected: List[str] = [] + for line in lines: + if re.match(r"^##\s+Summary\s*$", line): + in_summary = True + continue + if in_summary: + if line.startswith("#"): + break + if line.strip(): + collected.append(line.strip()) + elif collected: + break + return " ".join(collected) + + def _parse_speckit_requirements(self, spec_text: str) -> List[Requirement]: + """User stories and functional requirements from spec.md.""" + requirements: List[Requirement] = [] + lines = spec_text.splitlines() + + # User stories: heading plus the first paragraph as description + current_story: Optional[Requirement] = None + desc_lines: List[str] = [] + + def flush_story(): + nonlocal current_story, desc_lines + if current_story is not None: + current_story.description = " ".join(desc_lines).strip() + requirements.append(current_story) + current_story = None + desc_lines = [] + + for line in lines: + story_match = self._SPECKIT_STORY_RE.match(line) + if story_match: + flush_story() + priority_tag = story_match.group("prio") + current_story = Requirement( + req_id=f"US{story_match.group('num')}", + title=story_match.group("title"), + priority=( + self._SPECKIT_STORY_PRIORITY.get(priority_tag, "low") + if priority_tag else "medium" + ), + source="spec.md:user_story", + ) + continue + if current_story is not None: + # Description ends at the next heading or bold block + # (acceptance scenarios, priority rationale, ...) + if line.startswith("#") or line.lstrip().startswith("**"): + flush_story() + elif line.strip(): + desc_lines.append(line.strip()) + flush_story() + + # Functional requirements + for line in lines: + fr_match = self._SPECKIT_FR_RE.match(line) + if fr_match: + requirements.append(Requirement( + req_id=fr_match.group("id"), + title=fr_match.group("text"), + description=fr_match.group("text"), + source="spec.md:functional_requirement", + )) + + return requirements + + def _parse_speckit_tech_context(self, plan_text: str) -> Dict[str, str]: + """`**Key**: value` lines from the plan.md Technical Context section.""" + context: Dict[str, str] = {} + in_section = False + for line in plan_text.splitlines(): + if re.match(r"^##\s+Technical Context\s*$", line): + in_section = True + continue + if in_section: + if line.startswith("#"): + break + kv_match = re.match(r"^\*\*(?P<key>[^*]+)\*\*:\s*(?P<value>.+)$", line) + if kv_match: + context[kv_match.group("key").strip()] = kv_match.group("value").strip() + return context + + def _parse_speckit_tasks(self, tasks_text: str) -> List[SpecKitTask]: + """Task lines from tasks.md, keeping phase grouping and order.""" + tasks: List[SpecKitTask] = [] + seen_ids: set = set() + phase = "" + phase_index = -1 + + for line in tasks_text.splitlines(): + heading_match = re.match(r"^##\s+(.+?)\s*$", line) + if heading_match: + phase = heading_match.group(1) + phase_index += 1 + continue + + task_match = self._SPECKIT_TASK_RE.match(line) + if not task_match: + continue + + task_id = task_match.group("id") + if task_id in seen_ids: + raise ValueError( + f"Duplicate task id {task_id} in tasks.md — " + "task ids must be unique for deterministic slicing." + ) + seen_ids.add(task_id) + + description = task_match.group("desc").strip() + tasks.append(SpecKitTask( + task_id=task_id, + description=description, + parallel=task_match.group("parallel") is not None, + story=task_match.group("story"), + phase=phase, + phase_index=max(phase_index, 0), + file_paths=list(dict.fromkeys( + self._SPECKIT_PATH_RE.findall(description) + )), + completed=task_match.group("done") in ("x", "X"), + )) + + if not tasks: + raise ValueError( + "tasks.md contains no tasks — expected lines like " + "'- [ ] T001 [P] [US1] Description with file path'." + ) + + return tasks + # ========================================================================= # LEGACY FORMAT NORMALIZER # ========================================================================= @@ -847,6 +1135,97 @@ def get_llm_config(self, task_type: str) -> Optional[Dict]: return None +# ============================================================================= +# SPEC-KIT TASK -> TASK SLICE MAPPING +# ============================================================================= + +# Minimal agent-type inference from task file extensions; anything without a +# clear signal stays "general" (the slicer's richer strategies still apply to +# the requirements themselves). +_SPECKIT_AGENT_EXT = { + ".py": "backend", + ".sql": "backend", + ".ts": "frontend", + ".tsx": "frontend", + ".js": "frontend", + ".jsx": "frontend", + ".css": "frontend", + ".html": "frontend", +} + + +def _speckit_agent_type(task: Dict) -> str: + """Infer a slicer agent type from a task's file paths.""" + for path_str in task.get("file_paths", []): + if path_str.startswith("tests/") or Path(path_str).name.startswith("test_"): + return "testing" + agent = _SPECKIT_AGENT_EXT.get(Path(path_str).suffix) + if agent is not None: + return agent + return "general" + + +def speckit_tasks_to_slices(spec: NormalizedSpec, job_id: int = 0) -> List[Any]: + """Map spec-kit tasks (context_layers.tasks) onto slicer TaskSlice objects. + + Mapping per task line `[ID] [P?] [Story] description + file path`: + - ID -> slice_id ("sk-t001") and requirements/requirement_details id + - [P] -> can_parallelize + - [Story] -> feature (story label, e.g. "US1") + - phase order -> depth (phases execute in order; same depth may parallelize) + - file paths -> requirement_details[..]["file_paths"] + - sequential tasks (no [P]) -> depends_on chains to the previous task in + the same phase, mirroring spec-kit's same-file sequencing rule + - completed -> requirement_details[..]["completed"] (ignored for planning) + + Returns a list of TaskSlice ready for the planning_engine batching. + """ + # Lazy import to avoid a static spec_adapter -> slicer dependency cycle + from src.engine.slicer import Slicer, TaskSlice + + slices: List[TaskSlice] = [] + previous_in_phase: Dict[int, str] = {} + + for task in spec.context_layers.tasks: + slice_id = f"sk-{task['task_id'].lower()}" + parallel = bool(task.get("parallel", False)) + phase_index = int(task.get("phase_index", 0)) + + depends_on: List[str] = [] + if not parallel and phase_index in previous_in_phase: + depends_on = [previous_in_phase[phase_index]] + + slices.append(TaskSlice( + slice_id=slice_id, + depth=phase_index, + agent_type=_speckit_agent_type(task), + requirements=[task["task_id"]], + requirement_details=[{ + "id": task["task_id"], + "label": task["description"], + "description": task["description"], + "story": task.get("story"), + "phase": task.get("phase", ""), + "file_paths": task.get("file_paths", []), + "completed": bool(task.get("completed", False)), + }], + depends_on=depends_on, + can_parallelize=parallel, + estimated_tokens=Slicer.TOKENS_PER_REQ, + feature=task.get("story"), + )) + previous_in_phase[phase_index] = slice_id + + logger.info( + "speckit_tasks_sliced", + job_id=job_id, + total_slices=len(slices), + parallel_slices=sum(1 for s in slices if s.can_parallelize), + ) + + return slices + + # ============================================================================= # CONVENIENCE FUNCTIONS # ============================================================================= diff --git a/tests/engine/test_spec_adapter_speckit.py b/tests/engine/test_spec_adapter_speckit.py new file mode 100644 index 0000000..f892b7f --- /dev/null +++ b/tests/engine/test_spec_adapter_speckit.py @@ -0,0 +1,208 @@ +# tests/engine/test_spec_adapter_speckit.py +""" +Tests for SpecFormat.SPEC_KIT — the fifth spec input format. + +A spec-kit feature directory contains the artifact conventions produced by the +github/spec-kit workflow: spec.md (user stories + functional requirements), +plan.md (technical context) and tasks.md (task lines `[ID] [P?] [Story]` +with file paths, grouped into phases). The adapter normalizes it into +NormalizedSpec; speckit_tasks_to_slices maps parsed tasks onto TaskSlice +for the slicer/planning_engine chain. Execution stays with the engine — +this is an input format only. +""" +import shutil +from pathlib import Path + +import pytest + +from src.engine.spec_adapter import ( + NormalizedSpec, + SpecAdapter, + SpecFormat, + speckit_tasks_to_slices, +) + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "speckit_feature" + + +def _copy_fixture(tmp_path: Path, *, without: str | None = None) -> Path: + """Copy the committed fixture directory, optionally dropping one file.""" + target = tmp_path / "001-user-notes" + shutil.copytree(FIXTURE_DIR, target) + if without is not None: + (target / without).unlink() + return target + + +class TestSpecKitDetection: + def test_spec_format_has_spec_kit_member(self): + assert SpecFormat.SPEC_KIT.value == "spec_kit" + + def test_detects_speckit_directory(self): + adapter = SpecAdapter() + spec = adapter.load(FIXTURE_DIR) + assert isinstance(spec, NormalizedSpec) + assert adapter.last_format == SpecFormat.SPEC_KIT + + def test_json_detection_unchanged(self): + # Existing file-based formats must keep working exactly as before. + adapter = SpecAdapter() + spec = adapter.load(Path("tests/fixtures/minimal_requirements.json")) + assert adapter.last_format == SpecFormat.SIMPLE + assert len(spec.requirements) == 3 + + def test_documentation_detection_takes_precedence(self, tmp_path, monkeypatch): + # A directory that matches the DOCUMENTATION indicators keeps routing + # to the documentation normalizer even if spec-kit files are present. + target = _copy_fixture(tmp_path) + (target / "MASTER_DOCUMENT.md").write_text("# Master", encoding="utf-8") + + sentinel = NormalizedSpec( + project_name="doc", project_description="", requirements=[], + tech_stack={}, context_layers=None, raw_spec={}, + ) + monkeypatch.setattr( + SpecAdapter, "_normalize_documentation", lambda self, p: sentinel + ) + adapter = SpecAdapter() + assert adapter.load(target) is sentinel + assert adapter.last_format == SpecFormat.DOCUMENTATION + + def test_dir_without_tasks_md_is_not_speckit(self, tmp_path): + # spec.md alone is not a spec-kit feature directory; the existing + # directory fallback (spec file lookup) applies and fails as before. + target = _copy_fixture(tmp_path, without="tasks.md") + with pytest.raises(FileNotFoundError): + SpecAdapter().load(target) + + +class TestSpecKitParsing: + @pytest.fixture() + def spec(self) -> NormalizedSpec: + return SpecAdapter().load(FIXTURE_DIR) + + def test_project_name_from_spec_title(self, spec): + assert spec.project_name == "User Notes" + + def test_requirements_from_user_stories(self, spec): + ids = [r.req_id for r in spec.requirements] + assert "US1" in ids and "US2" in ids and "US3" in ids + us1 = next(r for r in spec.requirements if r.req_id == "US1") + assert us1.title == "Create note" + assert "capture ideas" in us1.description + assert us1.source == "spec.md:user_story" + + def test_requirements_from_functional_requirements(self, spec): + ids = [r.req_id for r in spec.requirements] + for fr in ("FR-001", "FR-002", "FR-003", "FR-004"): + assert fr in ids + fr1 = next(r for r in spec.requirements if r.req_id == "FR-001") + assert "non-empty title" in fr1.title + assert fr1.source == "spec.md:functional_requirement" + + def test_story_priority_mapping(self, spec): + by_id = {r.req_id: r for r in spec.requirements} + assert by_id["US1"].priority == "high" # P1 + assert by_id["US2"].priority == "medium" # P2 + assert by_id["US3"].priority == "low" # P3 + + def test_tech_stack_from_plan(self, spec): + assert spec.tech_stack["id"] == "spec_kit_stack" + assert spec.tech_stack["backend"]["language"] == "Python 3.11" + assert spec.tech_stack["backend"]["framework"] == "FastAPI" + assert spec.tech_stack["database"]["type"] == "PostgreSQL" + + def test_tasks_parsed_with_ids_flags_and_paths(self, spec): + tasks = spec.context_layers.tasks + assert len(tasks) == 10 + by_id = {t["task_id"]: t for t in tasks} + + assert by_id["T002"]["parallel"] is True + assert by_id["T001"]["parallel"] is False + + assert by_id["T004"]["story"] == "US1" + assert by_id["T004"]["file_paths"] == ["src/models/note.py"] + assert by_id["T001"]["story"] is None + + assert by_id["T003"]["file_paths"] == ["migrations/001_create_notes.sql"] + assert by_id["T009"]["completed"] is True + assert by_id["T008"]["completed"] is False + + def test_tasks_keep_phase_grouping(self, spec): + by_id = {t["task_id"]: t for t in spec.context_layers.tasks} + assert by_id["T001"]["phase"] == "Phase 1: Setup" + assert by_id["T004"]["phase"].startswith("Phase 3: User Story 1") + + def test_parse_is_deterministic(self): + first = SpecAdapter().load(FIXTURE_DIR).to_dict() + second = SpecAdapter().load(FIXTURE_DIR).to_dict() + assert first == second + + +class TestSpecKitTaskSlices: + @pytest.fixture() + def slices(self): + spec = SpecAdapter().load(FIXTURE_DIR) + return speckit_tasks_to_slices(spec, job_id=42) + + def test_one_slice_per_task(self, slices): + assert len(slices) == 10 + assert [s.slice_id for s in slices] == [ + f"sk-t{n:03d}" for n in range(1, 11) + ] + + def test_parallel_flag_maps_to_can_parallelize(self, slices): + by_id = {s.slice_id: s for s in slices} + assert by_id["sk-t002"].can_parallelize is True + assert by_id["sk-t001"].can_parallelize is False + assert by_id["sk-t007"].can_parallelize is True + + def test_depth_follows_phase_order(self, slices): + by_id = {s.slice_id: s for s in slices} + assert by_id["sk-t001"].depth == 0 # Phase 1 + assert by_id["sk-t003"].depth == 1 # Phase 2 + assert by_id["sk-t004"].depth == 2 # Phase 3 + assert by_id["sk-t010"].depth == 5 # Phase 6 + + def test_story_becomes_feature(self, slices): + by_id = {s.slice_id: s for s in slices} + assert by_id["sk-t004"].feature == "US1" + assert by_id["sk-t001"].feature is None + + def test_sequential_tasks_chain_within_phase(self, slices): + by_id = {s.slice_id: s for s in slices} + # T005 and T006 are sequential ([P] absent) and follow T004 in Phase 3. + assert by_id["sk-t005"].depends_on == ["sk-t004"] + assert by_id["sk-t006"].depends_on == ["sk-t005"] + # Parallel tasks carry no intra-phase dependency. + assert by_id["sk-t004"].depends_on == [] + assert by_id["sk-t007"].depends_on == [] + + def test_requirement_details_carry_file_paths(self, slices): + by_id = {s.slice_id: s for s in slices} + detail = by_id["sk-t004"].requirement_details[0] + assert detail["id"] == "T004" + assert detail["file_paths"] == ["src/models/note.py"] + + +class TestSpecKitFailClosed: + def test_missing_plan_md_raises_clear_error(self, tmp_path): + target = _copy_fixture(tmp_path, without="plan.md") + with pytest.raises(FileNotFoundError, match="plan.md"): + SpecAdapter().load(target) + + def test_tasks_md_without_tasks_raises(self, tmp_path): + target = _copy_fixture(tmp_path) + (target / "tasks.md").write_text("# Tasks: empty\n\nno task lines\n", encoding="utf-8") + with pytest.raises(ValueError, match="no tasks"): + SpecAdapter().load(target) + + def test_duplicate_task_id_raises(self, tmp_path): + target = _copy_fixture(tmp_path) + tasks = (target / "tasks.md").read_text(encoding="utf-8") + (target / "tasks.md").write_text( + tasks + "\n- [ ] T004 Duplicate id in src/models/other.py\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="[Dd]uplicate"): + SpecAdapter().load(target) diff --git a/tests/fixtures/speckit_feature/plan.md b/tests/fixtures/speckit_feature/plan.md new file mode 100644 index 0000000..e74f6f8 --- /dev/null +++ b/tests/fixtures/speckit_feature/plan.md @@ -0,0 +1,31 @@ +# Implementation Plan: User Notes + +**Branch**: `001-user-notes` | **Date**: 2026-08-14 | **Spec**: spec.md + +## Summary + +Implement note creation, listing and deletion as a small web feature: a REST API backed by +a relational store, with request validation and per-user ownership checks. + +## Technical Context + +**Language/Version**: Python 3.11 +**Primary Dependencies**: FastAPI +**Storage**: PostgreSQL +**Testing**: pytest +**Target Platform**: Linux server +**Project Type**: web +**Performance Goals**: p95 list latency under 200 ms +**Constraints**: single-tenant per user, no sharing in this feature + +## Project Structure + +```text +src/ +├── models/ +│ └── note.py +└── api/ + └── notes.py +tests/ +└── test_notes.py +``` diff --git a/tests/fixtures/speckit_feature/spec.md b/tests/fixtures/speckit_feature/spec.md new file mode 100644 index 0000000..6965c28 --- /dev/null +++ b/tests/fixtures/speckit_feature/spec.md @@ -0,0 +1,49 @@ +# Feature Specification: User Notes + +**Feature Branch**: `001-user-notes` +**Created**: 2026-08-14 +**Status**: Draft + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Create note (Priority: P1) + +As a registered user, I want to create a note with a title and body so that I can capture ideas quickly. + +**Why this priority**: Core value of the product; nothing works without note creation. + +**Acceptance Scenarios**: + +1. **Given** an authenticated user, **When** they submit a title and body, **Then** a note is persisted and returned with an id. +2. **Given** an empty title, **When** the user submits, **Then** the request is rejected with a validation error. + +### User Story 2 - List notes (Priority: P2) + +As a registered user, I want to see my notes ordered by last update so that I can find recent work first. + +**Why this priority**: Retrieval is the second half of the core loop. + +**Acceptance Scenarios**: + +1. **Given** three existing notes, **When** the user opens the list, **Then** all three appear ordered by update time descending. + +### User Story 3 - Delete note (Priority: P3) + +As a registered user, I want to delete a note so that outdated content disappears. + +**Acceptance Scenarios**: + +1. **Given** an existing note, **When** the user deletes it, **Then** it no longer appears in the list. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow creating a note with a non-empty title and an optional body. +- **FR-002**: System MUST return the persisted note including its generated id. +- **FR-003**: System MUST list a user's notes ordered by update time descending. +- **FR-004**: System MUST allow deleting a note owned by the requesting user. + +### Key Entities + +- **Note**: id, title, body, updated_at, owner_id diff --git a/tests/fixtures/speckit_feature/tasks.md b/tests/fixtures/speckit_feature/tasks.md new file mode 100644 index 0000000..9704ffb --- /dev/null +++ b/tests/fixtures/speckit_feature/tasks.md @@ -0,0 +1,37 @@ +# Tasks: User Notes + +**Input**: Design documents from `specs/001-user-notes/` +**Prerequisites**: plan.md (required), spec.md (required) + +**Organization**: Tasks are grouped by user story. `[P]` marks tasks that can run in parallel +because they touch different files and have no dependency on an incomplete task. + +## Phase 1: Setup + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 [P] Configure linting and formatting in pyproject.toml + +## Phase 2: Foundational + +- [ ] T003 Create database migration for notes table in migrations/001_create_notes.sql + +## Phase 3: User Story 1 - Create note (Priority: P1) + +- [ ] T004 [P] [US1] Create Note model in src/models/note.py +- [ ] T005 [US1] Implement POST /notes endpoint in src/api/notes.py +- [ ] T006 [US1] Add validation for empty titles in src/api/notes.py + +**Checkpoint**: User Story 1 is independently testable via POST /notes. + +## Phase 4: User Story 2 - List notes (Priority: P2) + +- [ ] T007 [P] [US2] Implement GET /notes endpoint in src/api/notes.py +- [ ] T008 [P] [US2] Add list ordering test in tests/test_notes.py + +## Phase 5: User Story 3 - Delete note (Priority: P3) + +- [x] T009 [US3] Implement DELETE /notes/{id} endpoint in src/api/notes.py + +## Phase 6: Polish + +- [ ] T010 [P] Update API documentation in docs/api.md