Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/claude-world/
|
||||||
Generated
+10
@@ -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
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/spec-kit.iml" filepath="$PROJECT_DIR$/.idea/spec-kit.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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 <name> [--agent claude]` | Scaffold `<name>/` with `.speckit/` state + templates, `memory/constitution.md`, empty `specs/`; `--agent claude` also installs `/speckit.*` command prompts into `.claude/commands/` |
|
||||||
|
| `specify <description or file> [--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 <NNN or slug>` | 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 <name>` | Seed a reviewer-owned quality checklist into `checklists/<name>.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).
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 "<the description>"` from the project —
|
||||||
|
or, when the user provides the brief as a file, pass its path instead
|
||||||
|
(add `--name "<short feature 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"project": "pantry-planner",
|
||||||
|
"current_feature": "001-pantry-meal-planner",
|
||||||
|
"features": [
|
||||||
|
"001-pantry-meal-planner"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 …
|
||||||
@@ -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: …
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
+477
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 "<the description>"` from the project —
|
||||||
|
or, when the user provides the brief as a file, pass its path instead
|
||||||
|
(add `--name "<short feature 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 …
|
||||||
@@ -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: …
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user