From e8c8224b0550d0b70a653a0ad124b10f75ad8191 Mon Sep 17 00:00:00 2001 From: juliaweber Date: Wed, 19 Aug 2026 11:03:16 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + .idea/.gitignore | 10 + .idea/go.imports.xml | 10 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/spec-kit.iml | 9 + .idea/vcs.xml | 6 + README.md | 96 +++ conftest.py | 2 + .../.claude/commands/speckit.implement.md | 13 + .../.claude/commands/speckit.plan.md | 13 + .../.claude/commands/speckit.specify.md | 18 + .../.claude/commands/speckit.tasks.md | 13 + examples/pantry-planner/.speckit/state.json | 7 + .../.speckit/templates/checklist-template.md | 13 + .../templates/constitution-template.md | 30 + .../.speckit/templates/plan-template.md | 33 + .../.speckit/templates/spec-template.md | 43 ++ .../.speckit/templates/tasks-template.md | 25 + .../pantry-planner/memory/constitution.md | 31 + .../checklists/requirements.md | 13 + .../specs/001-pantry-meal-planner/plan.md | 33 + .../specs/001-pantry-meal-planner/spec.md | 61 ++ .../specs/001-pantry-meal-planner/tasks.md | 23 + speckit.py | 477 ++++++++++++++ templates/checklist-template.md | 13 + templates/commands/speckit.implement.md | 13 + templates/commands/speckit.plan.md | 13 + templates/commands/speckit.specify.md | 18 + templates/commands/speckit.tasks.md | 13 + templates/constitution-template.md | 30 + templates/plan-template.md | 33 + templates/spec-template.md | 43 ++ templates/tasks-template.md | 25 + tests/test_speckit.py | 610 ++++++++++++++++++ 35 files changed, 1806 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/spec-kit.iml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 conftest.py create mode 100644 examples/pantry-planner/.claude/commands/speckit.implement.md create mode 100644 examples/pantry-planner/.claude/commands/speckit.plan.md create mode 100644 examples/pantry-planner/.claude/commands/speckit.specify.md create mode 100644 examples/pantry-planner/.claude/commands/speckit.tasks.md create mode 100644 examples/pantry-planner/.speckit/state.json create mode 100644 examples/pantry-planner/.speckit/templates/checklist-template.md create mode 100644 examples/pantry-planner/.speckit/templates/constitution-template.md create mode 100644 examples/pantry-planner/.speckit/templates/plan-template.md create mode 100644 examples/pantry-planner/.speckit/templates/spec-template.md create mode 100644 examples/pantry-planner/.speckit/templates/tasks-template.md create mode 100644 examples/pantry-planner/memory/constitution.md create mode 100644 examples/pantry-planner/specs/001-pantry-meal-planner/checklists/requirements.md create mode 100644 examples/pantry-planner/specs/001-pantry-meal-planner/plan.md create mode 100644 examples/pantry-planner/specs/001-pantry-meal-planner/spec.md create mode 100644 examples/pantry-planner/specs/001-pantry-meal-planner/tasks.md create mode 100644 speckit.py create mode 100644 templates/checklist-template.md create mode 100644 templates/commands/speckit.implement.md create mode 100644 templates/commands/speckit.plan.md create mode 100644 templates/commands/speckit.specify.md create mode 100644 templates/commands/speckit.tasks.md create mode 100644 templates/constitution-template.md create mode 100644 templates/plan-template.md create mode 100644 templates/spec-template.md create mode 100644 templates/tasks-template.md create mode 100644 tests/test_speckit.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d4e03f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/claude-world/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..dd4ead3 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..be17093 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/spec-kit.iml b/.idea/spec-kit.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/spec-kit.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8929292 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# speckit + +A fully local Spec-Driven Development (SDD) workflow CLI — a rebuild of the +idea behind [github/spec-kit](https://github.com/github/spec-kit) in a single +zero-dependency Python file. + +Instead of prompting an AI agent (or yourself) ad hoc, every feature walks a +gated artifact pipeline: + +``` +constitution → specify → clarify → plan → tasks → implement → converge +(principles) spec.md resolve plan.md tasks.md check off gap check + markers boxes +``` + +Gates are mechanical: `plan` refuses to run without `spec.md`, `tasks` without +`plan.md`, and `check` validates section structure, clarification markers +(cap: 3) and checklist state. No accounts, no API keys, no network — just +files. + +## Requirements + +Python 3. Nothing else at runtime (`pytest` only for developing speckit itself). + +## Quickstart + +```bash +python3 speckit.py init myproject --agent claude # scaffold (drop --agent for plain) +cd myproject +python3 ../speckit.py specify "pantry meal planner" # → specs/001-pantry-meal-planner/spec.md +# (or: specify prompt.txt --name "pantry planner" — reads the brief from the file) +# ... fill spec.md: stories, requirements, success criteria, assumptions ... +python3 ../speckit.py clarify # list open [NEEDS CLARIFICATION: …] markers +python3 ../speckit.py plan # → plan.md (gate: spec.md exists) +python3 ../speckit.py tasks # → tasks.md (gate: plan.md exists) +python3 ../speckit.py status # phases + task progress per feature +python3 ../speckit.py check # validate current feature (exit 0/1) +``` + +A worked example lives in [`examples/pantry-planner/`](examples/pantry-planner) — +a real feature driven through the entire pipeline. + +## Commands + +| Command | What it does | +|---|---| +| `init [--agent claude]` | Scaffold `/` with `.speckit/` state + templates, `memory/constitution.md`, empty `specs/`; `--agent claude` also installs `/speckit.*` command prompts into `.claude/commands/` | +| `specify [--name "…"] [--timestamp]` | Create `specs/NNN-slug/spec.md` and make it the current feature. The argument can be a quoted description **or a path to a file containing the prompt** (e.g. `specify prompt.txt`). Slug = first 4 words (sequential 001, 002, … or `YYYYMMDD-HHMMSS-` prefix); `--name` overrides it | +| `switch ` | Re-point the current feature | +| `plan` | Seed `plan.md` for the current feature — refuses while `spec.md` is missing | +| `tasks` | Seed `tasks.md` — refuses while `plan.md` is missing | +| `status` | All features, per-phase artifact presence, `* ` current marker, `N done / M open` task counts | +| `check` | Validate the current feature: required sections, marker cap (>3 fails), checklist warnings; exit 0 clean / 1 findings | +| `clarify` | List every `[NEEDS CLARIFICATION: …]` marker with `file:line` | +| `checklist ` | Seed a reviewer-owned quality checklist into `checklists/.md`; unchecked items make `check` warn (never fail) | +| `converge` | Append-only gap check: spec stories missing from `tasks.md` get new checkboxes under `### Convergence`; idempotent | +| `constitution [--amend "…" [--major]]` | Show the constitution version, or append a dated amendment with a semver bump | + +Global: `--color {auto,always,never}` (before the subcommand) — colors only on +a real terminal by default. + +All commands except `init` find the project by walking up from the current +directory to the nearest `.speckit/` — they work from anywhere inside a project +(exit 2 outside one). + +## Working with an AI agent + +`init --agent claude` installs four prompt files into `.claude/commands/`, so a +Claude Code session inside the project gets `/speckit.specify`, `/speckit.plan`, +`/speckit.tasks` and `/speckit.implement` — each prompt tells the agent to run +the CLI, fill the seeded artifact properly, and respect the phase gates +(implement additionally: work `tasks.md` top-to-bottom, test-first, check boxes +only after verification). The same files work as a howto if you drive the +workflow by hand. + +## Scaffolded project layout + +``` +myproject/ + .speckit/state.json # project name, current feature, feature list + .speckit/templates/ # your copy of the 5 templates (edit freely) + .claude/commands/ # agent prompts (with --agent claude) + memory/constitution.md # standing principles, semver-versioned + specs/001-some-feature/ + spec.md plan.md tasks.md checklists/*.md +``` + +## Developing speckit itself + +```bash +python3 -m pytest -q # 51 tests, run from the repo root +``` + +Everything lives in `speckit.py` (stdlib only); document templates in +`templates/`, agent prompts in `templates/commands/`, tests in +`tests/test_speckit.py` (strictly TDD). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..86aec88 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# Empty on purpose: marks the repo root as pytest rootdir and puts it on +# sys.path so tests can `import speckit`. diff --git a/examples/pantry-planner/.claude/commands/speckit.implement.md b/examples/pantry-planner/.claude/commands/speckit.implement.md new file mode 100644 index 0000000..9f4aa54 --- /dev/null +++ b/examples/pantry-planner/.claude/commands/speckit.implement.md @@ -0,0 +1,13 @@ +# /speckit.implement — execute the task list + +Gate: tasks.md exists for the current feature (run `python3 speckit.py status` +to confirm); if it is missing, stop and run /speckit.tasks first. + +1. Read spec.md, plan.md and tasks.md of the current feature. +2. Work through tasks.md strictly top to bottom, phase by phase: implement + each task test-first, and flip its checkbox to `- [x]` only after its + verification actually passed. +3. Never renumber or delete tasks; append newly discovered work as new + checkboxes at the end of the fitting phase. +4. When all boxes are checked, run `python3 speckit.py check` and report the + result to the user together with how to run the built feature. diff --git a/examples/pantry-planner/.claude/commands/speckit.plan.md b/examples/pantry-planner/.claude/commands/speckit.plan.md new file mode 100644 index 0000000..5796497 --- /dev/null +++ b/examples/pantry-planner/.claude/commands/speckit.plan.md @@ -0,0 +1,13 @@ +# /speckit.plan — create the implementation plan + +Gate: the current feature must already have a completed spec.md — read it +first; if it is missing or full of unresolved markers, stop and say so. + +1. Run `python3 speckit.py plan` — it seeds `plan.md` next to the spec + (the command refuses to run while spec.md is missing). +2. Fill plan.md from the spec: the concrete stack in ## Technical Context, + the intended source layout in ## Structure, and each significant choice + with rationale and rejected alternative in ## Decisions. +3. Check every decision against memory/constitution.md; justify or simplify + anything that violates an article. +4. Run `python3 speckit.py status` to confirm the phase is recorded. diff --git a/examples/pantry-planner/.claude/commands/speckit.specify.md b/examples/pantry-planner/.claude/commands/speckit.specify.md new file mode 100644 index 0000000..f58b2d5 --- /dev/null +++ b/examples/pantry-planner/.claude/commands/speckit.specify.md @@ -0,0 +1,18 @@ +# /speckit.specify — create a feature specification + +The user gives you a feature description. Turn it into a reviewable spec: + +1. Run `python3 speckit.py specify ""` from the project — + or, when the user provides the brief as a file, pass its path instead + (add `--name ""` when the file starts with metadata). + This creates `specs/NNN-slug/spec.md` and makes it the current feature. +2. Open that spec.md and replace every placeholder section with real content: + prioritized user stories with acceptance scenarios (## User Stories), + verifiable numbered requirements and key entities (## Requirements), + measurable technology-agnostic outcomes (## Success Criteria), and the + defaults you chose for open points (## Assumptions). +3. Where you truly cannot decide without the user, leave a marker of the form + NEEDS CLARIFICATION plus the question in square brackets — at most 3. +4. Finish by running `python3 speckit.py check` and resolving any findings. + +Do not make technology choices here — the spec says what and why, never how. diff --git a/examples/pantry-planner/.claude/commands/speckit.tasks.md b/examples/pantry-planner/.claude/commands/speckit.tasks.md new file mode 100644 index 0000000..d99b65d --- /dev/null +++ b/examples/pantry-planner/.claude/commands/speckit.tasks.md @@ -0,0 +1,13 @@ +# /speckit.tasks — break the plan into executable tasks + +Gate: the current feature needs a filled plan.md — read spec.md and plan.md +before writing anything; if plan.md is missing, stop and say so. + +1. Run `python3 speckit.py tasks` — it seeds `tasks.md` for the current + feature (refused while plan.md is missing). +2. Replace the placeholder checkboxes with real tasks: dependency-ordered + phases under ## Phases, one checkbox per task, exact file paths in each + description, user-story order preserved (P1 first). +3. Every task must be small enough to verify on its own; note which tasks + are independent enough to run in parallel. +4. Run `python3 speckit.py status` to see the open-task count. diff --git a/examples/pantry-planner/.speckit/state.json b/examples/pantry-planner/.speckit/state.json new file mode 100644 index 0000000..c8bbc02 --- /dev/null +++ b/examples/pantry-planner/.speckit/state.json @@ -0,0 +1,7 @@ +{ + "project": "pantry-planner", + "current_feature": "001-pantry-meal-planner", + "features": [ + "001-pantry-meal-planner" + ] +} diff --git a/examples/pantry-planner/.speckit/templates/checklist-template.md b/examples/pantry-planner/.speckit/templates/checklist-template.md new file mode 100644 index 0000000..ed84111 --- /dev/null +++ b/examples/pantry-planner/.speckit/templates/checklist-template.md @@ -0,0 +1,13 @@ +# Quality Checklist: {{FEATURE}} + +*Created: {{DATE}}* + +Reviewer-owned quality gate for the written requirements — these boxes judge +the spec text, not the implementation. Check an item only after actually +reviewing it; add domain-specific items freely. + +- [ ] Every user story has at least one concrete acceptance scenario +- [ ] Each requirement is verifiable (a test could pass or fail it) +- [ ] Success criteria are measurable without naming technologies +- [ ] All clarification markers are resolved or consciously accepted +- [ ] Assumptions state the chosen default AND what was ruled out diff --git a/examples/pantry-planner/.speckit/templates/constitution-template.md b/examples/pantry-planner/.speckit/templates/constitution-template.md new file mode 100644 index 0000000..6f09f6f --- /dev/null +++ b/examples/pantry-planner/.speckit/templates/constitution-template.md @@ -0,0 +1,30 @@ +# {{PROJECT}} Constitution + +*Adopted: {{DATE}} — version 1.0.0* + +Standing principles every feature in this project must respect. Specs, plans +and tasks are checked against these articles; a violation needs an explicit, +written justification or the work does not proceed. + +## Article I — Simplicity + +The smallest design that satisfies the spec wins. New layers, wrappers or +abstractions require a demonstrated need, not an anticipated one. + +## Article II — Tests Prove Behavior + +No behavior ships without a test that failed before the behavior existed. + +## Article III — Minimal Blast Radius + +Touch only what the feature requires. Leave unrelated code, files and +interfaces unchanged. + +## Article IV — Honest Artifacts + +Specs record what is actually decided; open questions are marked, not hidden. +Task checkboxes reflect verified reality, not intentions. + +## Amendments + +Record changes here with date, version bump and one-line rationale. diff --git a/examples/pantry-planner/.speckit/templates/plan-template.md b/examples/pantry-planner/.speckit/templates/plan-template.md new file mode 100644 index 0000000..450cfe9 --- /dev/null +++ b/examples/pantry-planner/.speckit/templates/plan-template.md @@ -0,0 +1,33 @@ +# Implementation Plan: {{FEATURE}} + +*Created: {{DATE}}* + +Turn the spec into concrete technical decisions. Stay minimal: prefer the +simplest structure that satisfies the spec, and justify anything that isn't. + +## Technical Context + +Language/runtime, dependencies, storage, testing approach, target platform, +and any performance or scale constraints. Unknowns become research notes here +— resolve them before writing tasks. + +- Language: +- Dependencies: +- Storage: +- Testing: + +## Structure + +The intended source layout: directories, modules, and where new code lands. + +``` +(project tree sketch) +``` + +## Decisions + +Each significant choice with its one-line rationale and the alternative that +was rejected. Anything that adds complexity needs a justification tied to a +requirement. + +- D1: … — because … diff --git a/examples/pantry-planner/.speckit/templates/spec-template.md b/examples/pantry-planner/.speckit/templates/spec-template.md new file mode 100644 index 0000000..d28daf4 --- /dev/null +++ b/examples/pantry-planner/.speckit/templates/spec-template.md @@ -0,0 +1,43 @@ +# Feature Specification: {{FEATURE}} + +*Created: {{DATE}}* + +> Original request: {{DESCRIPTION}} + +Anything genuinely undecided gets an inline marker: the words NEEDS CLARIFICATION +plus the open question, wrapped in square brackets. Keep at most 3 of them — pick +a sensible default for everything else and record it under Assumptions. + +## User Stories + +Describe who wants what and why. At least one story with priority P1; each story +must be independently testable. + +### Story 1 — (P1) + +As a …, I want …, so that …. + +**Acceptance:** Given …, when …, then …. + +## Requirements + +Numbered, verifiable statements of what the feature must do. Name the key +entities (data the feature reads or writes) at the end. + +- R1: The system must … + +**Entities:** … + +## Success Criteria + +Measurable, technology-agnostic outcomes that tell you the feature works +(counts, durations, rates — things you can check without knowing the stack). + +- S1: … + +## Assumptions + +Defaults chosen for anything the request left open, plus explicit scope +boundaries (what this feature deliberately does not cover). + +- A1: … diff --git a/examples/pantry-planner/.speckit/templates/tasks-template.md b/examples/pantry-planner/.speckit/templates/tasks-template.md new file mode 100644 index 0000000..211bd96 --- /dev/null +++ b/examples/pantry-planner/.speckit/templates/tasks-template.md @@ -0,0 +1,25 @@ +# Tasks: {{FEATURE}} + +*Created: {{DATE}}* + +Dependency-ordered, checkable work items derived from the spec and plan. +Work top to bottom; a phase starts only when the one before it is done. +Replace the placeholder tasks below with real ones — keep the checkbox format, +one task per line, exact file paths in the description. + +## Phases + +### Setup + +- [ ] T1: Prepare project scaffolding and test harness +- [ ] T2: Create data structures / entities from the spec + +### Implementation + +- [ ] T3: Implement Story 1 happy path (test first) +- [ ] T4: Implement edge cases and error handling for Story 1 +- [ ] T5: Wire the feature into the existing entry points + +### Polish + +- [ ] T6: Documentation touch-ups and final full test run diff --git a/examples/pantry-planner/memory/constitution.md b/examples/pantry-planner/memory/constitution.md new file mode 100644 index 0000000..c5f6107 --- /dev/null +++ b/examples/pantry-planner/memory/constitution.md @@ -0,0 +1,31 @@ +# pantry-planner Constitution + +*Adopted: 2026-08-18 — version 1.0.0* + +Standing principles every feature in this project must respect. Specs, plans +and tasks are checked against these articles; a violation needs an explicit, +written justification or the work does not proceed. + +## Article I — Simplicity + +The smallest design that satisfies the spec wins. New layers, wrappers or +abstractions require a demonstrated need, not an anticipated one. + +## Article II — Tests Prove Behavior + +No behavior ships without a test that failed before the behavior existed. + +## Article III — Minimal Blast Radius + +Touch only what the feature requires. Leave unrelated code, files and +interfaces unchanged. + +## Article IV — Honest Artifacts + +Specs record what is actually decided; open questions are marked, not hidden. +Task checkboxes reflect verified reality, not intentions. + +## Amendments + +Record changes here with date, version bump and one-line rationale. +- 2026-08-18 — version 1.1.0: Article V — data stays in plain local files the user can read diff --git a/examples/pantry-planner/specs/001-pantry-meal-planner/checklists/requirements.md b/examples/pantry-planner/specs/001-pantry-meal-planner/checklists/requirements.md new file mode 100644 index 0000000..66a43a9 --- /dev/null +++ b/examples/pantry-planner/specs/001-pantry-meal-planner/checklists/requirements.md @@ -0,0 +1,13 @@ +# Quality Checklist: 001-pantry-meal-planner + +*Created: 2026-08-18* + +Reviewer-owned quality gate for the written requirements — these boxes judge +the spec text, not the implementation. Check an item only after actually +reviewing it; add domain-specific items freely. + +- [x] Every user story has at least one concrete acceptance scenario +- [x] Each requirement is verifiable (a test could pass or fail it) +- [x] Success criteria are measurable without naming technologies +- [x] All clarification markers are resolved or consciously accepted +- [ ] Assumptions state the chosen default AND what was ruled out diff --git a/examples/pantry-planner/specs/001-pantry-meal-planner/plan.md b/examples/pantry-planner/specs/001-pantry-meal-planner/plan.md new file mode 100644 index 0000000..b45acc3 --- /dev/null +++ b/examples/pantry-planner/specs/001-pantry-meal-planner/plan.md @@ -0,0 +1,33 @@ +# Implementation Plan: 001-pantry-meal-planner + +*Created: 2026-08-18* + +Turn the spec into concrete technical decisions. Stay minimal: prefer the +simplest structure that satisfies the spec, and justify anything that isn't. + +## Technical Context + +- Language: Python 3 (stdlib only) +- Dependencies: none at runtime; pytest for tests +- Storage: two JSON files in the project dir — pantry.json, recipes.json + (starter set written on first run) +- Testing: pytest, TDD; planner logic tested against handwritten pantries + +## Structure + +``` +pantry_planner/ + planner.py # pantry CRUD, plan generation, shopping list derivation + cli.py # argparse: pantry add/remove/list, plan, shoplist + recipes.json # bundled starter set (10 recipes) + tests/test_planner.py +``` + +## Decisions + +- D1: Greedy day-by-day plan generation with stock simulation — because R2 only + demands feasibility, not optimality; rejected: constraint solver (overkill). +- D2: JSON files over SQLite — because two flat collections with <1k entries + need no queries; rejected: SQLite (adds schema/migration weight). +- D3: Shopping list = sum(plan needs) − stock, clamped at 0 per item (R3) — + computed on demand, never stored; rejected: persisting derived data. diff --git a/examples/pantry-planner/specs/001-pantry-meal-planner/spec.md b/examples/pantry-planner/specs/001-pantry-meal-planner/spec.md new file mode 100644 index 0000000..df0171f --- /dev/null +++ b/examples/pantry-planner/specs/001-pantry-meal-planner/spec.md @@ -0,0 +1,61 @@ +# Feature Specification: 001-pantry-meal-planner + +*Created: 2026-08-18* + +> Original request: pantry meal planner + +## User Stories + +### Story 1 — (P1) + +As a home cook, I want to record what is in my pantry, so that meal suggestions +are based on what I actually have. + +**Acceptance:** Given an empty pantry list, when I add "500 g rice" and +"2 cans tomatoes", then the pantry shows both items with their quantities. + +### Story 2 — (P1) + +As a home cook, I want a weekly plan of meals cookable from my pantry, so that +I don't have to decide every evening. + +**Acceptance:** Given a pantry with rice, tomatoes and lentils, when I request +a plan for 7 days, then every suggested meal uses only pantry items and no +item is over-consumed across the week. + +### Story 3 — (P2) + +As a home cook, I want a shopping list for the gaps, so that one store visit +covers the whole week. + +**Acceptance:** Given a 7-day plan that needs 3 onions while the pantry has 1, +when I open the shopping list, then it shows "2 onions" and nothing already +covered by the pantry. + +## Requirements + +- R1: The system must store pantry items as name + quantity + unit and support + add, update and remove. +- R2: The system must generate a 7-day plan where each meal's ingredients are + fully covered by the current pantry stock minus what earlier meals in the + same plan consume. +- R3: The system must derive a shopping list as the difference between a plan's + total ingredient needs and current stock. +- R4: Recipes come from a local, user-editable recipe file; a starter set of + 10 common recipes is bundled so the planner works out of the box. + +**Entities:** PantryItem (name, quantity, unit), Recipe (name, ingredients), +MealPlan (7 ordered days -> recipe), ShoppingList (derived). + +## Success Criteria + +- S1: Recording a 10-item pantry takes under 2 minutes. +- S2: A generated 7-day plan never references an ingredient with insufficient + stock at the point it is cooked. +- S3: Buying exactly the shopping list makes every meal of the week cookable. + +## Assumptions + +- A1: Single household, single device, no sharing — fully local data. +- A2: Quantities use metric units and piece counts; no unit conversion in v1. +- A3: One meal per day is planned (dinner); breakfast/lunch out of scope. diff --git a/examples/pantry-planner/specs/001-pantry-meal-planner/tasks.md b/examples/pantry-planner/specs/001-pantry-meal-planner/tasks.md new file mode 100644 index 0000000..3322bf4 --- /dev/null +++ b/examples/pantry-planner/specs/001-pantry-meal-planner/tasks.md @@ -0,0 +1,23 @@ +# Tasks: 001-pantry-meal-planner + +*Created: 2026-08-18* + +Dependency-ordered, checkable work items derived from the spec and plan. +Work top to bottom; a phase starts only when the one before it is done. + +## Phases + +### Setup + +- [x] T1: Scaffold pantry_planner/ package with pytest harness and empty modules per plan Structure +- [x] T2: Define PantryItem/Recipe dataclasses in pantry_planner/planner.py and JSON load/save for pantry.json + recipes.json (Story 1 entities) + +### Implementation + +- [x] T3: Story 1 — pantry CRUD in planner.py + `pantry add/remove/list` in cli.py, test-first (acceptance: add 500 g rice + 2 cans tomatoes, list shows both) +- [ ] T4: Story 2 — 7-day plan generation with stock simulation in planner.py (test: no ingredient over-consumed across the week, R2/S2) +- [ ] T5: Story 3 — shopping list derivation (plan needs − stock, clamp 0) + `shoplist` command (acceptance: 3 onions needed, 1 in stock → "2 onions") + +### Polish + +- [ ] T6: Bundle 10-recipe starter set (R4), README for the example, full test run diff --git a/speckit.py b/speckit.py new file mode 100644 index 0000000..bd006eb --- /dev/null +++ b/speckit.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""speckit — a local Spec-Driven Development workflow CLI. + +Rebuild of the idea behind github/spec-kit: a gated artifact pipeline per +feature (specify -> plan -> tasks -> implement) with mechanical gates. +""" +import argparse +import datetime +import json +import re +import shutil +import sys +from pathlib import Path + +# Bundled assets ship next to this script; user projects get their own copy. +BUNDLED_TEMPLATES = Path(__file__).resolve().parent / "templates" +TEMPLATE_NAMES = [ + "spec-template.md", + "plan-template.md", + "tasks-template.md", + "constitution-template.md", + "checklist-template.md", +] + + +_COLOR = False +ANSI = {"red": "31", "green": "32", "yellow": "33", "cyan": "36"} + + +def paint(text, color): + if not _COLOR: + return text + return f"\x1b[{ANSI[color]}m{text}\x1b[0m" + + +def substitute(text, tokens): + for key, value in tokens.items(): + text = text.replace("{{%s}}" % key, value) + return text + + +def today(): + return datetime.date.today().isoformat() + + +def fail(message, code=1): + print(message, file=sys.stderr) + return code + + +def find_root(): + """Walk up from cwd to the first directory containing .speckit/ (never + relative to this script's own location).""" + current = Path.cwd() + for candidate in [current, *current.parents]: + if (candidate / ".speckit").is_dir(): + return candidate + return None + + +def load_state(root): + return json.loads((root / ".speckit" / "state.json").read_text()) + + +def save_state(root, state): + (root / ".speckit" / "state.json").write_text(json.dumps(state, indent=2) + "\n") + + +def slugify(description): + """Lowercase, non-alphanumeric runs to single hyphens, first 4 words.""" + words = [w for w in re.split(r"[^a-z0-9]+", description.lower()) if w] + return "-".join(words[:4]) + + +def next_number(root): + numbers = [ + int(p.name[:3]) + for p in (root / "specs").glob("[0-9][0-9][0-9]-*") + if p.is_dir() + ] + return max(numbers, default=0) + 1 + + +def seed_from_template(root, template_name, target, tokens): + text = (root / ".speckit" / "templates" / template_name).read_text() + target.write_text(substitute(text, tokens)) + + +# Feature dirs are "NNN-slug" (sequential) or "YYYYMMDD-HHMMSS-slug" (timestamp). +RE_FEATURE_PREFIX = re.compile(r"^(?:\d{3}|\d{8}-\d{6})-(.+)$") + + +def feature_slug(feature): + match = RE_FEATURE_PREFIX.match(feature) + return match.group(1) if match else feature + + +def cmd_specify(root, args): + description = args.description + # A description that names an existing file means "read the prompt from + # that file" — typing a whole feature brief on the command line is no fun. + description_file = Path(description) + if description_file.is_file(): + description = description_file.read_text().strip() + print(f"read description from {args.description}") + slug = slugify(args.name) if args.name else slugify(description) + if not slug: + return fail("description produced an empty name") + if args.timestamp: + prefix = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + else: + prefix = f"{next_number(root):03d}" + feature = f"{prefix}-{slug}" + feature_dir = root / "specs" / feature + feature_dir.mkdir(parents=True) + seed_from_template( + root, "spec-template.md", feature_dir / "spec.md", + {"DESCRIPTION": description, "DATE": today(), "FEATURE": feature}, + ) + state = load_state(root) + state["features"].append(feature) + state["current_feature"] = feature + save_state(root, state) + print(f"created {feature_dir.relative_to(root)}/spec.md (current feature: {feature})") + return 0 + + +def current_feature_dir(root): + """Return (feature, dir) for the current feature, or None if unset.""" + feature = load_state(root)["current_feature"] + if feature is None: + return None + return feature, root / "specs" / feature + + +def seed_phase(root, template_name, target_name, requires): + """Shared gate logic for plan/tasks: needs a current feature and the + previous phase's artifact; never overwrites.""" + current = current_feature_dir(root) + if current is None: + return fail("no feature selected — run specify first") + feature, feature_dir = current + if not (feature_dir / requires).is_file(): + return fail(f"gate: {feature_dir / requires} is missing — complete that phase first") + target = feature_dir / target_name + if target.is_file(): + print(f"{target.relative_to(root)} exists — leaving it untouched") + return 0 + seed_from_template( + root, template_name, target, {"DATE": today(), "FEATURE": feature} + ) + print(f"created {target.relative_to(root)}") + return 0 + + +# Checkbox regexes: "-" or "*" bullets, optional indentation, [x]/[X] done. +RE_TASK_OPEN = re.compile(r"^\s*[-*] \[ \]") +RE_TASK_DONE = re.compile(r"^\s*[-*] \[[xX]\]") + +RE_CLARIFICATION = re.compile(r"\[NEEDS CLARIFICATION") +RE_CLARIFICATION_FULL = re.compile(r"\[NEEDS CLARIFICATION:?\s*(.*?)\]") +MAX_CLARIFICATIONS = 3 # flag only when strictly more + +REQUIRED_SECTIONS = { + "spec": ["## User Stories", "## Requirements", "## Success Criteria", "## Assumptions"], + "plan": ["## Technical Context", "## Structure", "## Decisions"], + "tasks": ["## Phases"], +} + + +def task_counts(tasks_file): + lines = tasks_file.read_text().splitlines() + done = sum(1 for l in lines if RE_TASK_DONE.match(l)) + open_ = sum(1 for l in lines if RE_TASK_OPEN.match(l)) + return done, open_ + + +def cmd_status(root, args): + state = load_state(root) + print(f"project: {state['project']}") + if not state["features"]: + print("no features yet — run specify") + return 0 + for feature in state["features"]: + feature_dir = root / "specs" / feature + marker = "* " if feature == state["current_feature"] else " " + phases = [] + for artifact in ["spec", "plan", "tasks"]: + present = (feature_dir / f"{artifact}.md").is_file() + phases.append(f"{artifact}:{'yes' if present else 'no'}") + counts = "" + tasks_file = feature_dir / "tasks.md" + if tasks_file.is_file(): + done, open_ = task_counts(tasks_file) + counts = " " + paint(f"{done} done / {open_} open", "cyan") + line = f"{marker}{feature} {' '.join(phases)}{counts}" + print(paint(line, "green") if marker == "* " else line) + return 0 + + +RE_STORY_HEADING = re.compile(r"^### Story (\d+)", re.MULTILINE) +RE_VERSION = re.compile(r"version (\d+)\.(\d+)\.(\d+)") + + +def cmd_constitution(root, args): + constitution_file = root / "memory" / "constitution.md" + if not constitution_file.is_file(): + return fail("memory/constitution.md is missing — run init (or restore it)") + text = constitution_file.read_text() + versions = RE_VERSION.findall(text) + if not versions: + return fail("constitution has no 'version X.Y.Z' line") + major, minor, patch = map(int, versions[-1]) # last entry = current + + if args.amend is None: + print(f"constitution version {major}.{minor}.{patch}") + return 0 + + if args.major: + major, minor, patch = major + 1, 0, 0 + else: + minor, patch = minor + 1, 0 + new_version = f"{major}.{minor}.{patch}" + entry = f"- {today()} — version {new_version}: {args.amend}\n" + if "## Amendments" in text: + if not text.endswith("\n"): + text += "\n" + text += entry + else: + text += f"\n## Amendments\n\n{entry}" + constitution_file.write_text(text) + print(f"amended constitution — version {new_version}") + return 0 + + +def cmd_converge(root, args): + """Append-only gap check: every spec story must appear in tasks.md.""" + current = current_feature_dir(root) + if current is None: + return fail("no feature selected — run specify first") + feature, feature_dir = current + tasks_file = feature_dir / "tasks.md" + if not tasks_file.is_file(): + return fail(f"gate: {feature_dir / 'tasks.md'} is missing — run tasks first") + + spec_text = (feature_dir / "spec.md").read_text() if (feature_dir / "spec.md").is_file() else "" + tasks_text = tasks_file.read_text() + missing = [ + number for number in RE_STORY_HEADING.findall(spec_text) + if f"Story {number}" not in tasks_text + ] + if not missing: + print(f"{feature}: converged — every story is covered in tasks.md") + return 0 + + addition = "" + if "### Convergence" not in tasks_text: + addition += "\n### Convergence\n\n" + for i, number in enumerate(missing, start=1): + addition += f"- [ ] C{i}: cover Story {number} from spec.md with implementation tasks\n" + tasks_file.write_text(tasks_text + addition) + print(f"appended {len(missing)} convergence task(s) for: " + ", ".join(f"Story {n}" for n in missing)) + return 0 + + +def cmd_checklist(root, args): + current = current_feature_dir(root) + if current is None: + return fail("no feature selected — run specify first") + feature, feature_dir = current + target = feature_dir / "checklists" / f"{args.name}.md" + if target.is_file(): + print(f"{target.relative_to(root)} exists — leaving it untouched") + return 0 + target.parent.mkdir(exist_ok=True) + seed_from_template( + root, "checklist-template.md", target, {"DATE": today(), "FEATURE": feature} + ) + print(f"created {target.relative_to(root)}") + return 0 + + +def cmd_clarify(root, args): + current = current_feature_dir(root) + if current is None: + return fail("no feature selected — run specify first") + _feature, feature_dir = current + found = False + for artifact in ["spec.md", "plan.md", "tasks.md"]: + artifact_file = feature_dir / artifact + if not artifact_file.is_file(): + continue + for lineno, line in enumerate(artifact_file.read_text().splitlines(), start=1): + for match in RE_CLARIFICATION_FULL.finditer(line): + print(f"{artifact}:{lineno}: {match.group(1)}") + found = True + if not found: + print("no clarification markers — spec is decided") + return 0 + + +def cmd_check(root, args): + current = current_feature_dir(root) + if current is None: + return fail("no feature selected — run specify first") + feature, feature_dir = current + findings = [] + + if not (root / "memory" / "constitution.md").is_file(): + findings.append("memory/constitution.md is missing") + + spec_file = feature_dir / "spec.md" + if not spec_file.is_file(): + findings.append(f"{feature}/spec.md is missing") + markers = 0 + else: + spec_text = spec_file.read_text() + for heading in REQUIRED_SECTIONS["spec"]: + if heading not in spec_text: + findings.append(f"spec.md: required section {heading} is missing") + markers = len(RE_CLARIFICATION.findall(spec_text)) + if markers > MAX_CLARIFICATIONS: + findings.append( + f"spec.md: {markers} clarification markers (cap is {MAX_CLARIFICATIONS}) — resolve some" + ) + + # Later phases are validated only if they exist (check scope contract). + for artifact in ["plan", "tasks"]: + artifact_file = feature_dir / f"{artifact}.md" + if not artifact_file.is_file(): + print(f"{artifact}.md: phase not started") + continue + text = artifact_file.read_text() + for heading in REQUIRED_SECTIONS[artifact]: + if heading not in text: + findings.append(f"{artifact}.md: required section {heading} is missing") + + print(f"NEEDS CLARIFICATION: {markers}") + tasks_file = feature_dir / "tasks.md" + if tasks_file.is_file(): + done, open_ = task_counts(tasks_file) + print(f"tasks: {done} done / {open_} open") + + # Checklists are reviewer-owned: unchecked items warn, they never fail. + for checklist in sorted((feature_dir / "checklists").glob("*.md")) if (feature_dir / "checklists").is_dir() else []: + _, unchecked = task_counts(checklist) + if unchecked: + print(paint(f"warning: checklist {checklist.stem}: {unchecked} unchecked", "yellow")) + + if findings: + for finding in findings: + print(paint(f"FINDING: {finding}", "red")) + return 1 + print(paint(f"{feature}: clean", "green")) + return 0 + + +def cmd_plan(root, args): + return seed_phase(root, "plan-template.md", "plan.md", requires="spec.md") + + +def cmd_tasks(root, args): + return seed_phase(root, "tasks-template.md", "tasks.md", requires="plan.md") + + +def cmd_switch(root, args): + state = load_state(root) + matches = [ + f for f in state["features"] + if f == args.feature + or f[:3] == args.feature.zfill(3) + or feature_slug(f) == args.feature + ] + if len(matches) != 1: + return fail( + "unknown feature %r — known: %s" % (args.feature, ", ".join(state["features"]) or "(none)") + ) + state["current_feature"] = matches[0] + save_state(root, state) + print(f"current feature: {matches[0]}") + return 0 + + +def cmd_init(args): + project = Path.cwd() / args.name + speckit_dir = project / ".speckit" + if speckit_dir.exists(): + return fail(f"{speckit_dir} already exists — refusing to overwrite") + + (speckit_dir / "templates").mkdir(parents=True) + for name in TEMPLATE_NAMES: + shutil.copy(BUNDLED_TEMPLATES / name, speckit_dir / "templates" / name) + + (project / "specs").mkdir() + (project / "memory").mkdir() + constitution = (BUNDLED_TEMPLATES / "constitution-template.md").read_text() + (project / "memory" / "constitution.md").write_text( + substitute(constitution, {"PROJECT": args.name, "DATE": today()}) + ) + + state = {"project": args.name, "current_feature": None, "features": []} + (speckit_dir / "state.json").write_text(json.dumps(state, indent=2) + "\n") + + if args.agent == "claude": + commands_dir = project / ".claude" / "commands" + commands_dir.mkdir(parents=True) + for prompt in sorted((BUNDLED_TEMPLATES / "commands").glob("*.md")): + shutil.copy(prompt, commands_dir / prompt.name) + + print(f"initialized speckit project in {project}") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="speckit", + description="Spec-Driven Development workflow: specify, plan, tasks, implement.", + ) + parser.add_argument( + "--color", choices=["auto", "always", "never"], default="auto", + help="colorize output (auto: only on a terminal)", + ) + sub = parser.add_subparsers( + dest="command", + metavar="{init,specify,plan,tasks,switch,status,check,clarify,constitution,checklist,converge}", + ) + + p_init = sub.add_parser("init", help="scaffold a new speckit project") + p_init.add_argument("name") + p_init.add_argument("--agent", choices=["claude"], help="also install agent command prompts") + p_specify = sub.add_parser("specify", help="create a numbered feature with a seeded spec.md") + p_specify.add_argument("description", help="feature description, or path to a file containing it") + p_specify.add_argument("--name", help="override the auto-derived feature slug") + p_specify.add_argument("--timestamp", action="store_true", help="YYYYMMDD-HHMMSS prefix instead of NNN") + sub.add_parser("plan", help="seed plan.md for the current feature (requires spec.md)") + sub.add_parser("tasks", help="seed tasks.md for the current feature (requires plan.md)") + p_switch = sub.add_parser("switch", help="switch the current feature") + p_switch.add_argument("feature", help="feature number (001) or slug") + sub.add_parser("status", help="show features, phases and task progress") + sub.add_parser("check", help="validate the current feature's artifacts") + sub.add_parser("clarify", help="list open [NEEDS CLARIFICATION] markers with file:line") + p_constitution = sub.add_parser("constitution", help="show or amend the project constitution") + p_constitution.add_argument("--amend", help="append an amendment and bump the version") + p_constitution.add_argument("--major", action="store_true", help="major version bump for --amend") + p_checklist = sub.add_parser("checklist", help="seed a quality checklist for the current feature") + p_checklist.add_argument("name") + sub.add_parser("converge", help="append tasks for spec stories missing from tasks.md") + + args = parser.parse_args(argv) + global _COLOR + _COLOR = args.color == "always" or (args.color == "auto" and sys.stdout.isatty()) + if args.command is None: + parser.print_help() + return 0 + if args.command == "init": + return cmd_init(args) + + root = find_root() + if root is None: + return fail("not inside a speckit project (no .speckit/ found)", code=2) + handlers = { + "specify": cmd_specify, + "switch": cmd_switch, + "plan": cmd_plan, + "tasks": cmd_tasks, + "status": cmd_status, + "check": cmd_check, + "clarify": cmd_clarify, + "checklist": cmd_checklist, + "converge": cmd_converge, + "constitution": cmd_constitution, + } + return handlers[args.command](root, args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/checklist-template.md b/templates/checklist-template.md new file mode 100644 index 0000000..ed84111 --- /dev/null +++ b/templates/checklist-template.md @@ -0,0 +1,13 @@ +# Quality Checklist: {{FEATURE}} + +*Created: {{DATE}}* + +Reviewer-owned quality gate for the written requirements — these boxes judge +the spec text, not the implementation. Check an item only after actually +reviewing it; add domain-specific items freely. + +- [ ] Every user story has at least one concrete acceptance scenario +- [ ] Each requirement is verifiable (a test could pass or fail it) +- [ ] Success criteria are measurable without naming technologies +- [ ] All clarification markers are resolved or consciously accepted +- [ ] Assumptions state the chosen default AND what was ruled out diff --git a/templates/commands/speckit.implement.md b/templates/commands/speckit.implement.md new file mode 100644 index 0000000..9f4aa54 --- /dev/null +++ b/templates/commands/speckit.implement.md @@ -0,0 +1,13 @@ +# /speckit.implement — execute the task list + +Gate: tasks.md exists for the current feature (run `python3 speckit.py status` +to confirm); if it is missing, stop and run /speckit.tasks first. + +1. Read spec.md, plan.md and tasks.md of the current feature. +2. Work through tasks.md strictly top to bottom, phase by phase: implement + each task test-first, and flip its checkbox to `- [x]` only after its + verification actually passed. +3. Never renumber or delete tasks; append newly discovered work as new + checkboxes at the end of the fitting phase. +4. When all boxes are checked, run `python3 speckit.py check` and report the + result to the user together with how to run the built feature. diff --git a/templates/commands/speckit.plan.md b/templates/commands/speckit.plan.md new file mode 100644 index 0000000..5796497 --- /dev/null +++ b/templates/commands/speckit.plan.md @@ -0,0 +1,13 @@ +# /speckit.plan — create the implementation plan + +Gate: the current feature must already have a completed spec.md — read it +first; if it is missing or full of unresolved markers, stop and say so. + +1. Run `python3 speckit.py plan` — it seeds `plan.md` next to the spec + (the command refuses to run while spec.md is missing). +2. Fill plan.md from the spec: the concrete stack in ## Technical Context, + the intended source layout in ## Structure, and each significant choice + with rationale and rejected alternative in ## Decisions. +3. Check every decision against memory/constitution.md; justify or simplify + anything that violates an article. +4. Run `python3 speckit.py status` to confirm the phase is recorded. diff --git a/templates/commands/speckit.specify.md b/templates/commands/speckit.specify.md new file mode 100644 index 0000000..f58b2d5 --- /dev/null +++ b/templates/commands/speckit.specify.md @@ -0,0 +1,18 @@ +# /speckit.specify — create a feature specification + +The user gives you a feature description. Turn it into a reviewable spec: + +1. Run `python3 speckit.py specify ""` from the project — + or, when the user provides the brief as a file, pass its path instead + (add `--name ""` when the file starts with metadata). + This creates `specs/NNN-slug/spec.md` and makes it the current feature. +2. Open that spec.md and replace every placeholder section with real content: + prioritized user stories with acceptance scenarios (## User Stories), + verifiable numbered requirements and key entities (## Requirements), + measurable technology-agnostic outcomes (## Success Criteria), and the + defaults you chose for open points (## Assumptions). +3. Where you truly cannot decide without the user, leave a marker of the form + NEEDS CLARIFICATION plus the question in square brackets — at most 3. +4. Finish by running `python3 speckit.py check` and resolving any findings. + +Do not make technology choices here — the spec says what and why, never how. diff --git a/templates/commands/speckit.tasks.md b/templates/commands/speckit.tasks.md new file mode 100644 index 0000000..d99b65d --- /dev/null +++ b/templates/commands/speckit.tasks.md @@ -0,0 +1,13 @@ +# /speckit.tasks — break the plan into executable tasks + +Gate: the current feature needs a filled plan.md — read spec.md and plan.md +before writing anything; if plan.md is missing, stop and say so. + +1. Run `python3 speckit.py tasks` — it seeds `tasks.md` for the current + feature (refused while plan.md is missing). +2. Replace the placeholder checkboxes with real tasks: dependency-ordered + phases under ## Phases, one checkbox per task, exact file paths in each + description, user-story order preserved (P1 first). +3. Every task must be small enough to verify on its own; note which tasks + are independent enough to run in parallel. +4. Run `python3 speckit.py status` to see the open-task count. diff --git a/templates/constitution-template.md b/templates/constitution-template.md new file mode 100644 index 0000000..6f09f6f --- /dev/null +++ b/templates/constitution-template.md @@ -0,0 +1,30 @@ +# {{PROJECT}} Constitution + +*Adopted: {{DATE}} — version 1.0.0* + +Standing principles every feature in this project must respect. Specs, plans +and tasks are checked against these articles; a violation needs an explicit, +written justification or the work does not proceed. + +## Article I — Simplicity + +The smallest design that satisfies the spec wins. New layers, wrappers or +abstractions require a demonstrated need, not an anticipated one. + +## Article II — Tests Prove Behavior + +No behavior ships without a test that failed before the behavior existed. + +## Article III — Minimal Blast Radius + +Touch only what the feature requires. Leave unrelated code, files and +interfaces unchanged. + +## Article IV — Honest Artifacts + +Specs record what is actually decided; open questions are marked, not hidden. +Task checkboxes reflect verified reality, not intentions. + +## Amendments + +Record changes here with date, version bump and one-line rationale. diff --git a/templates/plan-template.md b/templates/plan-template.md new file mode 100644 index 0000000..450cfe9 --- /dev/null +++ b/templates/plan-template.md @@ -0,0 +1,33 @@ +# Implementation Plan: {{FEATURE}} + +*Created: {{DATE}}* + +Turn the spec into concrete technical decisions. Stay minimal: prefer the +simplest structure that satisfies the spec, and justify anything that isn't. + +## Technical Context + +Language/runtime, dependencies, storage, testing approach, target platform, +and any performance or scale constraints. Unknowns become research notes here +— resolve them before writing tasks. + +- Language: +- Dependencies: +- Storage: +- Testing: + +## Structure + +The intended source layout: directories, modules, and where new code lands. + +``` +(project tree sketch) +``` + +## Decisions + +Each significant choice with its one-line rationale and the alternative that +was rejected. Anything that adds complexity needs a justification tied to a +requirement. + +- D1: … — because … diff --git a/templates/spec-template.md b/templates/spec-template.md new file mode 100644 index 0000000..d28daf4 --- /dev/null +++ b/templates/spec-template.md @@ -0,0 +1,43 @@ +# Feature Specification: {{FEATURE}} + +*Created: {{DATE}}* + +> Original request: {{DESCRIPTION}} + +Anything genuinely undecided gets an inline marker: the words NEEDS CLARIFICATION +plus the open question, wrapped in square brackets. Keep at most 3 of them — pick +a sensible default for everything else and record it under Assumptions. + +## User Stories + +Describe who wants what and why. At least one story with priority P1; each story +must be independently testable. + +### Story 1 — (P1) + +As a …, I want …, so that …. + +**Acceptance:** Given …, when …, then …. + +## Requirements + +Numbered, verifiable statements of what the feature must do. Name the key +entities (data the feature reads or writes) at the end. + +- R1: The system must … + +**Entities:** … + +## Success Criteria + +Measurable, technology-agnostic outcomes that tell you the feature works +(counts, durations, rates — things you can check without knowing the stack). + +- S1: … + +## Assumptions + +Defaults chosen for anything the request left open, plus explicit scope +boundaries (what this feature deliberately does not cover). + +- A1: … diff --git a/templates/tasks-template.md b/templates/tasks-template.md new file mode 100644 index 0000000..211bd96 --- /dev/null +++ b/templates/tasks-template.md @@ -0,0 +1,25 @@ +# Tasks: {{FEATURE}} + +*Created: {{DATE}}* + +Dependency-ordered, checkable work items derived from the spec and plan. +Work top to bottom; a phase starts only when the one before it is done. +Replace the placeholder tasks below with real ones — keep the checkbox format, +one task per line, exact file paths in the description. + +## Phases + +### Setup + +- [ ] T1: Prepare project scaffolding and test harness +- [ ] T2: Create data structures / entities from the spec + +### Implementation + +- [ ] T3: Implement Story 1 happy path (test first) +- [ ] T4: Implement edge cases and error handling for Story 1 +- [ ] T5: Wire the feature into the existing entry points + +### Polish + +- [ ] T6: Documentation touch-ups and final full test run diff --git a/tests/test_speckit.py b/tests/test_speckit.py new file mode 100644 index 0000000..b119275 --- /dev/null +++ b/tests/test_speckit.py @@ -0,0 +1,610 @@ +"""Tests for speckit — local rebuild of the spec-kit idea (TDD).""" +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SPECKIT = REPO / "speckit.py" + +SUBCOMMANDS = [ + "init", "specify", "plan", "tasks", "switch", "status", "check", + "clarify", "constitution", "checklist", "converge", +] + + +def run_cli(*args, cwd=None): + return subprocess.run( + [sys.executable, str(SPECKIT), *args], + capture_output=True, text=True, cwd=cwd, + ) + + +# --- T1: skeleton --- + +def test_help_exits_zero_and_lists_all_subcommands(): + result = run_cli("--help") + assert result.returncode == 0 + for sub in SUBCOMMANDS: + assert sub in result.stdout + + +# --- T2: templates --- +# Heading strings are hard-coded literals here on purpose: they pin the +# REQUIRED_SECTIONS constant from outside (anti-tautology). + +TEMPLATES = REPO / "templates" + + +def test_spec_template_headings_and_tokens(): + text = (TEMPLATES / "spec-template.md").read_text() + for heading in ["## User Stories", "## Requirements", "## Success Criteria", "## Assumptions"]: + assert heading in text + for token in ["{{DESCRIPTION}}", "{{DATE}}", "{{FEATURE}}"]: + assert token in text + + +def test_plan_template_headings_and_tokens(): + text = (TEMPLATES / "plan-template.md").read_text() + for heading in ["## Technical Context", "## Structure", "## Decisions"]: + assert heading in text + for token in ["{{DATE}}", "{{FEATURE}}"]: + assert token in text + + +def test_tasks_template_heading_and_exactly_six_open_boxes(): + text = (TEMPLATES / "tasks-template.md").read_text() + assert "## Phases" in text + open_boxes = [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))] + assert len(open_boxes) == 6 + + +def test_constitution_template_tokens(): + text = (TEMPLATES / "constitution-template.md").read_text() + for token in ["{{PROJECT}}", "{{DATE}}"]: + assert token in text + + +# --- T3: init --- + +import json + + +def test_init_creates_exact_scaffold(tmp_path): + result = run_cli("init", "demo", cwd=tmp_path) + assert result.returncode == 0 + proj = tmp_path / "demo" + assert (proj / ".speckit" / "state.json").is_file() + for name in ["spec-template.md", "plan-template.md", "tasks-template.md", "constitution-template.md"]: + assert (proj / ".speckit" / "templates" / name).is_file() + assert (proj / "memory" / "constitution.md").is_file() + assert (proj / "specs").is_dir() + assert list((proj / "specs").iterdir()) == [] + + state = json.loads((proj / ".speckit" / "state.json").read_text()) + assert state == {"project": "demo", "current_feature": None, "features": []} + + constitution = (proj / "memory" / "constitution.md").read_text() + assert "{{" not in constitution + assert "demo" in constitution + + +def test_init_refuses_existing_project(tmp_path): + assert run_cli("init", "demo", cwd=tmp_path).returncode == 0 + result = run_cli("init", "demo", cwd=tmp_path) + assert result.returncode == 1 + assert ".speckit" in result.stderr + + +# --- T4: specify + switch + root discovery --- + +def make_project(tmp_path, name="demo"): + assert run_cli("init", name, cwd=tmp_path).returncode == 0 + return tmp_path / name + + +def read_state(proj): + return json.loads((proj / ".speckit" / "state.json").read_text()) + + +def test_specify_creates_first_feature_001(tmp_path): + proj = make_project(tmp_path) + result = run_cli("specify", "recipe box organizer", cwd=proj) + assert result.returncode == 0 + spec = proj / "specs" / "001-recipe-box-organizer" / "spec.md" + assert spec.is_file() + text = spec.read_text() + assert "recipe box organizer" in text + assert "{{" not in text + state = read_state(proj) + assert state["current_feature"] == "001-recipe-box-organizer" + assert state["features"] == ["001-recipe-box-organizer"] + + +def test_specify_increments_number_and_switches_current(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("specify", "meal shopping list", cwd=proj) + assert result.returncode == 0 + assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file() + assert read_state(proj)["current_feature"] == "002-meal-shopping-list" + + +def test_slug_keeps_first_four_words(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "A Really long, feature name here!", cwd=proj) + assert (proj / "specs" / "001-a-really-long-feature").is_dir() + + +def test_switch_by_number_and_slug(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + run_cli("specify", "meal shopping list", cwd=proj) + assert run_cli("switch", "001", cwd=proj).returncode == 0 + assert read_state(proj)["current_feature"] == "001-recipe-box-organizer" + assert run_cli("switch", "meal-shopping-list", cwd=proj).returncode == 0 + assert read_state(proj)["current_feature"] == "002-meal-shopping-list" + + +def test_switch_unknown_feature_lists_known(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("switch", "nope", cwd=proj) + assert result.returncode == 1 + assert "001-recipe-box-organizer" in result.stderr + + +def test_command_outside_project_exits_2_and_writes_nothing(tmp_path): + before = sorted(p.name for p in tmp_path.iterdir()) + result = run_cli("specify", "anything", cwd=tmp_path) + assert result.returncode == 2 + assert "not inside a speckit project" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == before + + +def test_root_discovery_walks_up_from_nested_subdir(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + nested = proj / "specs" / "001-recipe-box-organizer" + result = run_cli("specify", "meal shopping list", cwd=nested) + assert result.returncode == 0 + assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file() + assert not (nested / "specs").exists() + + +# --- T5: plan/tasks phase gates --- + +def test_plan_without_feature_exits_1(tmp_path): + proj = make_project(tmp_path) + result = run_cli("plan", cwd=proj) + assert result.returncode == 1 + assert "no feature selected" in result.stderr + + +def test_plan_seeds_plan_md(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("plan", cwd=proj) + assert result.returncode == 0 + plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md" + assert plan.is_file() + text = plan.read_text() + assert "001-recipe-box-organizer" in text + assert "{{" not in text + + +def test_plan_never_overwrites(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + run_cli("plan", cwd=proj) + plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md" + plan.write_text("edited by hand") + result = run_cli("plan", cwd=proj) + assert result.returncode == 0 + assert "exists" in result.stdout + assert plan.read_text() == "edited by hand" + + +def test_plan_gate_requires_spec(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + (proj / "specs" / "001-recipe-box-organizer" / "spec.md").unlink() + result = run_cli("plan", cwd=proj) + assert result.returncode == 1 + assert "spec.md" in result.stderr + + +def test_tasks_gate_requires_plan(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("tasks", cwd=proj) + assert result.returncode == 1 + assert "plan.md" in result.stderr + + +def test_tasks_seeds_tasks_md_after_plan(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + run_cli("plan", cwd=proj) + result = run_cli("tasks", cwd=proj) + assert result.returncode == 0 + tasks = proj / "specs" / "001-recipe-box-organizer" / "tasks.md" + assert tasks.is_file() + assert "{{" not in tasks.read_text() + + +# --- T6: status --- +# Fixture counts below are written BY HAND (3 done, 4 open), never derived +# from the code's own regexes. + +TASKS_FIXTURE = """# Tasks +## Phases +- [x] plain done + - [X] indented uppercase done +* [x] star bullet done +- [ ] plain open + - [ ] indented open +* [ ] star bullet open +- [ ] fourth open +""" + + +def test_status_on_empty_project(tmp_path): + proj = make_project(tmp_path) + result = run_cli("status", cwd=proj) + assert result.returncode == 0 + assert "no features" in result.stdout + + +def test_status_shows_phases_current_and_counts(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + run_cli("plan", cwd=proj) + run_cli("tasks", cwd=proj) + run_cli("specify", "meal shopping list", cwd=proj) + run_cli("switch", "001", cwd=proj) + (proj / "specs" / "001-recipe-box-organizer" / "tasks.md").write_text(TASKS_FIXTURE) + + result = run_cli("status", cwd=proj) + assert result.returncode == 0 + out = result.stdout + assert "* 001-recipe-box-organizer" in out # current marker + assert "3 done / 4 open" in out # hand-counted fixture + lines_002 = [l for l in out.splitlines() if "002-meal-shopping-list" in l] + assert len(lines_002) == 1 + assert not lines_002[0].startswith("* ") + assert "spec" in lines_002[0] + + +# --- T7: check --- + +def full_feature(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + run_cli("plan", cwd=proj) + run_cli("tasks", cwd=proj) + return proj, proj / "specs" / "001-recipe-box-organizer" + + +def test_check_clean_full_feature_exits_0(tmp_path): + proj, _ = full_feature(tmp_path) + result = run_cli("check", cwd=proj) + assert result.returncode == 0 + assert "NEEDS CLARIFICATION: 0" in result.stdout + + +def test_check_without_feature_exits_1(tmp_path): + proj = make_project(tmp_path) + result = run_cli("check", cwd=proj) + assert result.returncode == 1 + assert "no feature selected" in result.stderr + + +def test_check_spec_only_feature_is_clean(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("check", cwd=proj) + assert result.returncode == 0 + assert result.stdout.count("phase not started") == 2 # plan + tasks + + +def test_check_exactly_three_markers_passes(tmp_path): + proj, feature = full_feature(tmp_path) + spec = feature / "spec.md" + spec.write_text( + spec.read_text() + + "\n- R2: sorting order [NEEDS CLARIFICATION: by date or name?]" + + "\n- R3: limits [NEEDS CLARIFICATION: max photos?]" + + "\n- R4: sharing [NEEDS CLARIFICATION: export format?]\n" + ) + result = run_cli("check", cwd=proj) + assert result.returncode == 0 + assert "NEEDS CLARIFICATION: 3" in result.stdout + + +def test_check_flags_missing_heading_and_four_markers(tmp_path): + proj, feature = full_feature(tmp_path) + spec = feature / "spec.md" + text = spec.read_text().replace("## Requirements", "## Stuff") + text += "".join( + f"\n- R{i}: thing [NEEDS CLARIFICATION: q{i}?]" for i in range(4) + ) + spec.write_text(text) + result = run_cli("check", cwd=proj) + assert result.returncode == 1 + assert "## Requirements" in result.stdout + assert "NEEDS CLARIFICATION: 4" in result.stdout + + +def test_check_missing_spec_is_finding(tmp_path): + proj, feature = full_feature(tmp_path) + (feature / "spec.md").unlink() + result = run_cli("check", cwd=proj) + assert result.returncode == 1 + assert "spec.md" in result.stdout + + +def test_check_missing_constitution_is_finding(tmp_path): + proj, _ = full_feature(tmp_path) + (proj / "memory" / "constitution.md").unlink() + result = run_cli("check", cwd=proj) + assert result.returncode == 1 + assert "constitution" in result.stdout + + +# --- T8: agent command prompts --- + +import re as _re + +PROMPT_FILES = { + "speckit.specify.md": "spec.md", + "speckit.plan.md": "plan.md", + "speckit.tasks.md": "tasks.md", + "speckit.implement.md": "tasks.md", +} +PROMPT_GATES = { + "speckit.plan.md": "spec.md", # plan gated on spec + "speckit.tasks.md": "plan.md", # tasks gated on plan + "speckit.implement.md": "tasks.md", # implement gated on tasks existing +} + + +def test_init_with_agent_writes_four_prompts_naming_artifact_and_gate(tmp_path): + assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0 + commands = tmp_path / "demo" / ".claude" / "commands" + for name, artifact in PROMPT_FILES.items(): + text = (commands / name).read_text() + assert artifact in text + for name, gate in PROMPT_GATES.items(): + assert gate in (commands / name).read_text() + + +def test_init_without_agent_writes_no_claude_dir(tmp_path): + assert run_cli("init", "demo", cwd=tmp_path).returncode == 0 + assert not (tmp_path / "demo" / ".claude").exists() + + +# --- specify from file + --name override (bug: `specify prompt.txt` made 001-prompt-txt) --- + +def test_specify_reads_description_from_existing_file(tmp_path): + proj = make_project(tmp_path) + (proj / "prompt.txt").write_text("superhero visual room overview\n\nContext: show all installed agents.\n") + result = run_cli("specify", "prompt.txt", cwd=proj) + assert result.returncode == 0 + assert "prompt.txt" in result.stdout # says it read the file + feature_dir = proj / "specs" / "001-superhero-visual-room-overview" + assert feature_dir.is_dir() # slug from content, not filename + spec_text = (feature_dir / "spec.md").read_text() + assert "show all installed agents" in spec_text + + +def test_specify_name_overrides_slug(tmp_path): + proj = make_project(tmp_path) + (proj / "prompt.txt").write_text("title: Fancy Thing category: builds\nlots of prose here") + result = run_cli("specify", "prompt.txt", "--name", "visual room", cwd=proj) + assert result.returncode == 0 + assert (proj / "specs" / "001-visual-room").is_dir() + assert "lots of prose here" in (proj / "specs" / "001-visual-room" / "spec.md").read_text() + + +# --- O5: timestamp numbering --- + +def test_specify_timestamp_mode_prefixes_datetime(tmp_path): + proj = make_project(tmp_path) + result = run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj) + assert result.returncode == 0 + dirs = [p.name for p in (proj / "specs").iterdir()] + assert len(dirs) == 1 + assert _re.fullmatch(r"\d{8}-\d{6}-recipe-box-organizer", dirs[0]) + assert read_state(proj)["current_feature"] == dirs[0] + + +def test_switch_by_slug_works_for_timestamp_feature(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj) + run_cli("specify", "meal shopping list", cwd=proj) + assert run_cli("switch", "recipe-box-organizer", cwd=proj).returncode == 0 + assert read_state(proj)["current_feature"].endswith("-recipe-box-organizer") + + +# --- O4: checklists --- + +def test_checklist_seeds_file_with_open_items(tmp_path): + proj, feature = full_feature(tmp_path) + result = run_cli("checklist", "requirements", cwd=proj) + assert result.returncode == 0 + checklist = feature / "checklists" / "requirements.md" + assert checklist.is_file() + text = checklist.read_text() + assert "{{" not in text + assert RE_OPEN_BOX_LINES(text) + + +def RE_OPEN_BOX_LINES(text): + return [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))] + + +def test_checklist_without_feature_exits_1(tmp_path): + proj = make_project(tmp_path) + result = run_cli("checklist", "requirements", cwd=proj) + assert result.returncode == 1 + assert "no feature selected" in result.stderr + + +def test_checklist_never_overwrites(tmp_path): + proj, feature = full_feature(tmp_path) + run_cli("checklist", "requirements", cwd=proj) + target = feature / "checklists" / "requirements.md" + target.write_text("edited by hand") + result = run_cli("checklist", "requirements", cwd=proj) + assert result.returncode == 0 + assert "exists" in result.stdout + assert target.read_text() == "edited by hand" + + +def test_check_warns_on_unchecked_checklist_items_but_stays_clean(tmp_path): + proj, feature = full_feature(tmp_path) + run_cli("checklist", "requirements", cwd=proj) + n_open = len(RE_OPEN_BOX_LINES((feature / "checklists" / "requirements.md").read_text())) + result = run_cli("check", cwd=proj) + assert result.returncode == 0 + assert f"checklist requirements: {n_open} unchecked" in result.stdout + + +# --- O6: converge --- + +def test_converge_reports_converged_and_leaves_tasks_untouched(tmp_path): + proj, feature = full_feature(tmp_path) + before = (feature / "tasks.md").read_bytes() + result = run_cli("converge", cwd=proj) + assert result.returncode == 0 + assert "converged" in result.stdout + assert (feature / "tasks.md").read_bytes() == before + + +def test_converge_appends_tasks_for_uncovered_stories(tmp_path): + proj, feature = full_feature(tmp_path) + spec = feature / "spec.md" + spec.write_text(spec.read_text() + "\n### Story 2 — (P2)\n\nAs a cook, I want tags.\n") + before = (feature / "tasks.md").read_text() + + result = run_cli("converge", cwd=proj) + assert result.returncode == 0 + after = (feature / "tasks.md").read_text() + assert after.startswith(before) # append-only + appended = after[len(before):] + assert "Story 2" in appended + assert "### Convergence" in appended + assert RE_OPEN_BOX_LINES(appended) + + # idempotent: second run finds nothing new + result2 = run_cli("converge", cwd=proj) + assert result2.returncode == 0 + assert "converged" in result2.stdout + assert (feature / "tasks.md").read_text() == after + + +def test_converge_requires_tasks_md(tmp_path): + proj = make_project(tmp_path) + run_cli("specify", "recipe box organizer", cwd=proj) + result = run_cli("converge", cwd=proj) + assert result.returncode == 1 + assert "tasks.md" in result.stderr + + +# --- O1: ANSI colors --- + +def test_color_always_emits_ansi_codes(tmp_path): + proj, _ = full_feature(tmp_path) + result = run_cli("--color", "always", "status", cwd=proj) + assert result.returncode == 0 + assert "\x1b[" in result.stdout + + +def test_no_color_by_default_when_not_a_tty(tmp_path): + proj, _ = full_feature(tmp_path) + result = run_cli("status", cwd=proj) + assert result.returncode == 0 + assert "\x1b[" not in result.stdout + + +def test_color_always_paints_findings_red(tmp_path): + proj, feature = full_feature(tmp_path) + (feature / "spec.md").unlink() + result = run_cli("--color", "always", "check", cwd=proj) + assert result.returncode == 1 + assert "\x1b[31m" in result.stdout + + +# --- O3: constitution --- + +def test_constitution_shows_current_version(tmp_path): + proj = make_project(tmp_path) + result = run_cli("constitution", cwd=proj) + assert result.returncode == 0 + assert "1.0.0" in result.stdout + + +def test_constitution_amend_bumps_minor_and_records_entry(tmp_path): + proj = make_project(tmp_path) + result = run_cli("constitution", "--amend", "Article V — no network calls at runtime", cwd=proj) + assert result.returncode == 0 + text = (proj / "memory" / "constitution.md").read_text() + assert "version 1.1.0" in text + assert "no network calls at runtime" in text + assert run_cli("constitution", cwd=proj).stdout.strip().endswith("1.1.0") + + +def test_constitution_amend_major_bump(tmp_path): + proj = make_project(tmp_path) + run_cli("constitution", "--amend", "first change", cwd=proj) # 1.1.0 + result = run_cli("constitution", "--amend", "breaking change", "--major", cwd=proj) + assert result.returncode == 0 + assert "2.0.0" in (proj / "memory" / "constitution.md").read_text() + + +def test_constitution_missing_file_exits_1(tmp_path): + proj = make_project(tmp_path) + (proj / "memory" / "constitution.md").unlink() + result = run_cli("constitution", cwd=proj) + assert result.returncode == 1 + assert "constitution" in result.stderr + + +# --- O2: clarify --- + +def test_clarify_lists_markers_with_file_and_line(tmp_path): + proj, feature = full_feature(tmp_path) + spec = feature / "spec.md" + lines = spec.read_text().splitlines() + lines.append("- R2: order [NEEDS CLARIFICATION: by date or name?]") + spec.write_text("\n".join(lines) + "\n") + marker_line = len(lines) # appended last, 1-based + + result = run_cli("clarify", cwd=proj) + assert result.returncode == 0 + assert f"spec.md:{marker_line}:" in result.stdout + assert "by date or name?" in result.stdout + + +def test_clarify_reports_when_clean(tmp_path): + proj, _ = full_feature(tmp_path) + result = run_cli("clarify", cwd=proj) + assert result.returncode == 0 + assert "no clarification markers" in result.stdout + + +def test_clarify_without_feature_exits_1(tmp_path): + proj = make_project(tmp_path) + result = run_cli("clarify", cwd=proj) + assert result.returncode == 1 + assert "no feature selected" in result.stderr + + +def test_prompt_verbs_are_registered_subcommands(tmp_path): + assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0 + commands = tmp_path / "demo" / ".claude" / "commands" + verbs = set() + for prompt in commands.glob("*.md"): + verbs.update(_re.findall(r"speckit\.py (\w+)", prompt.read_text())) + assert verbs # prompts must actually reference the CLI + assert verbs <= set(SUBCOMMANDS)