Skip to content

Commit 43cb0fa

Browse files
authored
feat: add bundled lean preset with minimal workflow commands (#2161)
* feat: add bundled lean preset with minimal workflow commands Add a lean preset that overrides the 5 core workflow commands (specify, plan, tasks, implement, constitution) with minimal prompts that produce exactly one artifact each — no extension hooks, no scripts, no git branching, no templates. Bundled preset infrastructure: - Add _locate_bundled_preset() mirroring _locate_bundled_extension() - Update 'specify init --preset' to try bundled -> catalog fallback - Update 'specify preset add' to try bundled -> catalog fallback - Add bundled guard in download_pack() for presets without download URLs - Add lean to presets/catalog.json with 'bundled: true' marker - Add lean to pyproject.toml force-include for wheel packaging - Align error messages with bundled extension error pattern Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator) * refactor: address review — clean up unused imports, strengthen test assertions - Remove unused MagicMock import and cache_dir setup in download test - Assert 'bundled' and 'reinstall' in CLI error output (not just exit code) - Mock catalog in missing-locally test for deterministic bundled error path - Fix test versions to satisfy updated speckit_version >=0.6.0 requirement * refactor: address review — fix constitution paths, add REINSTALL_COMMAND to presets.py - Fix constitution path to .specify/memory/constitution.md in plan, tasks, implement commands (matching core command convention) - Include REINSTALL_COMMAND in download_pack() bundled guard for consistent recovery instructions across bundled extensions and presets * refactor: address review — explicit feature_directory paths, ZIP cleanup in finally - Prefix spec.md/plan.md/tasks.md with <feature_directory>/ in plan, tasks, and implement commands so the agent doesn't operate on repo root by mistake - Move ZIP unlink into finally block in init --preset path so cleanup runs even when install_from_zip raises (matching preset_add pattern) * refactor: address review — replace Unicode em dashes with ASCII, fix grammar - Replace all Unicode em dashes with ASCII hyphens in preset.yml and catalog.json to avoid decode errors on non-UTF-8 environments - Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md' * refactor: address review - align task format between tasks and implement - Remove undefined [P] marker from implement (lean uses sequential execution) - Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]' - Simplify implement to execute tasks in order without parallel complexity * refactor: address review - parse frontmatter instead of raw substring search - Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts keys in YAML frontmatter instead of brittle 'scripts:' substring search
1 parent 74e3f45 commit 43cb0fa

File tree

11 files changed

+454
-35
lines changed

11 files changed

+454
-35
lines changed

presets/catalog.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
{
22
"schema_version": "1.0",
3-
"updated_at": "2026-03-10T00:00:00Z",
3+
"updated_at": "2026-04-10T00:00:00Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
5-
"presets": {}
5+
"presets": {
6+
"lean": {
7+
"name": "Lean Workflow",
8+
"id": "lean",
9+
"version": "1.0.0",
10+
"description": "Minimal core workflow commands - just the prompt, just the artifact",
11+
"author": "github",
12+
"repository": "https://github.com/github/spec-kit",
13+
"bundled": true,
14+
"tags": [
15+
"lean",
16+
"minimal",
17+
"workflow",
18+
"core"
19+
]
20+
}
21+
}
622
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
description: Create or update the project constitution.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Outline
12+
13+
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
14+
- Project name, guiding principles, non-negotiable rules
15+
- Derive from user input and existing repo context (README, docs)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
description: Execute the implementation plan by processing all tasks in tasks.md.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Outline
12+
13+
1. Read `.specify/feature.json` to get the feature directory path.
14+
15+
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md` and `<feature_directory>/tasks.md`.
16+
17+
3. **Execute tasks** in order:
18+
- Complete each task before moving to the next
19+
- Mark completed tasks by changing `- [ ]` to `- [x]` in `<feature_directory>/tasks.md`
20+
- Halt on failure and report the issue
21+
22+
4. **Validate**: Verify all tasks are completed and the implementation matches the spec.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
description: Create a plan and store it in plan.md.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Outline
12+
13+
1. Read `.specify/feature.json` to get the feature directory path.
14+
15+
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md`.
16+
17+
3. Create an implementation plan and store it in `<feature_directory>/plan.md`.
18+
- Technical context: tech stack, dependencies, project structure
19+
- Design decisions, architecture, file structure
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
description: Create a specification and store it in spec.md.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Outline
12+
13+
1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided.
14+
15+
2. Create the directory and write `.specify/feature.json`:
16+
```json
17+
{ "feature_directory": "<feature_directory>" }
18+
```
19+
20+
3. Create a specification from the user input and store it in `<feature_directory>/spec.md`.
21+
- Overview, functional requirements, user scenarios, success criteria
22+
- Every requirement must be testable
23+
- Make informed defaults for unspecified details
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
description: Create the tasks needed for implementation and store them in tasks.md.
3+
---
4+
5+
## User Input
6+
7+
```text
8+
$ARGUMENTS
9+
```
10+
11+
## Outline
12+
13+
1. Read `.specify/feature.json` to get the feature directory path.
14+
15+
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md`.
16+
17+
3. Create dependency-ordered implementation tasks and store them in `<feature_directory>/tasks.md`.
18+
- Every task uses checklist format: `- [ ] [TaskID] Description with file path`
19+
- Organized by phase: setup, foundational, user stories in priority order, polish

presets/lean/preset.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
schema_version: "1.0"
2+
3+
preset:
4+
id: "lean"
5+
name: "Lean Workflow"
6+
version: "1.0.0"
7+
description: "Minimal core workflow commands - just the prompt, just the artifact"
8+
author: "github"
9+
repository: "https://github.com/github/spec-kit"
10+
license: "MIT"
11+
12+
requires:
13+
speckit_version: ">=0.6.0"
14+
15+
provides:
16+
templates:
17+
- type: "command"
18+
name: "speckit.specify"
19+
file: "commands/speckit.specify.md"
20+
description: "Lean specify - create spec.md from a feature description"
21+
replaces: "speckit.specify"
22+
23+
- type: "command"
24+
name: "speckit.plan"
25+
file: "commands/speckit.plan.md"
26+
description: "Lean plan - create plan.md from the spec"
27+
replaces: "speckit.plan"
28+
29+
- type: "command"
30+
name: "speckit.tasks"
31+
file: "commands/speckit.tasks.md"
32+
description: "Lean tasks - create tasks.md from plan and spec"
33+
replaces: "speckit.tasks"
34+
35+
- type: "command"
36+
name: "speckit.implement"
37+
file: "commands/speckit.implement.md"
38+
description: "Lean implement - execute tasks from tasks.md"
39+
replaces: "speckit.implement"
40+
41+
- type: "command"
42+
name: "speckit.constitution"
43+
file: "commands/speckit.constitution.md"
44+
description: "Lean constitution - create or update project constitution"
45+
replaces: "speckit.constitution"
46+
47+
tags:
48+
- "lean"
49+
- "minimal"
50+
- "workflow"

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ packages = ["src/specify_cli"]
4141
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
4242
# Bundled extensions (installable via `specify extension add <name>`)
4343
"extensions/git" = "specify_cli/core_pack/extensions/git"
44+
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
45+
"presets/lean" = "specify_cli/core_pack/presets/lean"
4446

4547
[project.optional-dependencies]
4648
test = [

src/specify_cli/__init__.py

Lines changed: 97 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None:
621621
return None
622622

623623

624+
def _locate_bundled_preset(preset_id: str) -> Path | None:
625+
"""Return the path to a bundled preset, or None.
626+
627+
Checks the wheel's core_pack first, then falls back to the
628+
source-checkout ``presets/<id>/`` directory.
629+
"""
630+
import re as _re
631+
if not _re.match(r'^[a-z0-9-]+$', preset_id):
632+
return None
633+
634+
core = _locate_core_pack()
635+
if core is not None:
636+
candidate = core / "presets" / preset_id
637+
if (candidate / "preset.yml").is_file():
638+
return candidate
639+
640+
# Source-checkout / editable install: look relative to repo root
641+
repo_root = Path(__file__).parent.parent.parent
642+
candidate = repo_root / "presets" / preset_id
643+
if (candidate / "preset.yml").is_file():
644+
return candidate
645+
646+
return None
647+
648+
624649
def _install_shared_infra(
625650
project_path: Path,
626651
script_type: str,
@@ -1266,27 +1291,44 @@ def init(
12661291
preset_manager = PresetManager(project_path)
12671292
speckit_ver = get_speckit_version()
12681293

1269-
# Try local directory first, then catalog
1294+
# Try local directory first, then bundled, then catalog
12701295
local_path = Path(preset).resolve()
12711296
if local_path.is_dir() and (local_path / "preset.yml").exists():
12721297
preset_manager.install_from_directory(local_path, speckit_ver)
12731298
else:
1274-
preset_catalog = PresetCatalog(project_path)
1275-
pack_info = preset_catalog.get_pack_info(preset)
1276-
if not pack_info:
1277-
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
1299+
bundled_path = _locate_bundled_preset(preset)
1300+
if bundled_path:
1301+
preset_manager.install_from_directory(bundled_path, speckit_ver)
12781302
else:
1279-
try:
1280-
zip_path = preset_catalog.download_pack(preset)
1281-
preset_manager.install_from_zip(zip_path, speckit_ver)
1282-
# Clean up downloaded ZIP to avoid cache accumulation
1303+
preset_catalog = PresetCatalog(project_path)
1304+
pack_info = preset_catalog.get_pack_info(preset)
1305+
if not pack_info:
1306+
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
1307+
elif pack_info.get("bundled") and not pack_info.get("download_url"):
1308+
from .extensions import REINSTALL_COMMAND
1309+
console.print(
1310+
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
1311+
f"but could not be found in the installed package."
1312+
)
1313+
console.print(
1314+
"This usually means the spec-kit installation is incomplete or corrupted."
1315+
)
1316+
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
1317+
else:
1318+
zip_path = None
12831319
try:
1284-
zip_path.unlink(missing_ok=True)
1285-
except OSError:
1286-
# Best-effort cleanup; failure to delete is non-fatal
1287-
pass
1288-
except PresetError as preset_err:
1289-
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
1320+
zip_path = preset_catalog.download_pack(preset)
1321+
preset_manager.install_from_zip(zip_path, speckit_ver)
1322+
except PresetError as preset_err:
1323+
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
1324+
finally:
1325+
if zip_path is not None:
1326+
# Clean up downloaded ZIP to avoid cache accumulation
1327+
try:
1328+
zip_path.unlink(missing_ok=True)
1329+
except OSError:
1330+
# Best-effort cleanup; failure to delete is non-fatal
1331+
pass
12901332
except Exception as preset_err:
12911333
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
12921334

@@ -2140,28 +2182,50 @@ def preset_add(
21402182
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
21412183

21422184
elif pack_id:
2143-
catalog = PresetCatalog(project_root)
2144-
pack_info = catalog.get_pack_info(pack_id)
2185+
# Try bundled preset first, then catalog
2186+
bundled_path = _locate_bundled_preset(pack_id)
2187+
if bundled_path:
2188+
console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...")
2189+
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
2190+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
2191+
else:
2192+
catalog = PresetCatalog(project_root)
2193+
pack_info = catalog.get_pack_info(pack_id)
21452194

2146-
if not pack_info:
2147-
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
2148-
raise typer.Exit(1)
2195+
if not pack_info:
2196+
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
2197+
raise typer.Exit(1)
21492198

2150-
if not pack_info.get("_install_allowed", True):
2151-
catalog_name = pack_info.get("_catalog_name", "unknown")
2152-
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
2153-
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
2154-
raise typer.Exit(1)
2199+
# Bundled presets should have been caught above; if we reach
2200+
# here the bundled files are missing from the installation.
2201+
if pack_info.get("bundled") and not pack_info.get("download_url"):
2202+
from .extensions import REINSTALL_COMMAND
2203+
console.print(
2204+
f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit "
2205+
f"but could not be found in the installed package."
2206+
)
2207+
console.print(
2208+
"\nThis usually means the spec-kit installation is incomplete or corrupted."
2209+
)
2210+
console.print("Try reinstalling spec-kit:")
2211+
console.print(f" {REINSTALL_COMMAND}")
2212+
raise typer.Exit(1)
21552213

2156-
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
2214+
if not pack_info.get("_install_allowed", True):
2215+
catalog_name = pack_info.get("_catalog_name", "unknown")
2216+
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
2217+
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
2218+
raise typer.Exit(1)
21572219

2158-
try:
2159-
zip_path = catalog.download_pack(pack_id)
2160-
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
2161-
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
2162-
finally:
2163-
if 'zip_path' in locals() and zip_path.exists():
2164-
zip_path.unlink(missing_ok=True)
2220+
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
2221+
2222+
try:
2223+
zip_path = catalog.download_pack(pack_id)
2224+
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
2225+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
2226+
finally:
2227+
if 'zip_path' in locals() and zip_path.exists():
2228+
zip_path.unlink(missing_ok=True)
21652229
else:
21662230
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
21672231
raise typer.Exit(1)

src/specify_cli/presets.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,6 +1587,16 @@ def download_pack(
15871587
f"Preset '{pack_id}' not found in catalog"
15881588
)
15891589

1590+
# Bundled presets without a download URL must be installed locally
1591+
if pack_info.get("bundled") and not pack_info.get("download_url"):
1592+
from .extensions import REINSTALL_COMMAND
1593+
raise PresetError(
1594+
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
1595+
f"It should be installed from the local package. "
1596+
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
1597+
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
1598+
)
1599+
15901600
if not pack_info.get("_install_allowed", True):
15911601
catalog_name = pack_info.get("_catalog_name", "unknown")
15921602
raise PresetError(

0 commit comments

Comments
 (0)