Files
2026-08-19 11:03:16 +02:00

478 lines
17 KiB
Python

#!/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())