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