478 lines
17 KiB
Python
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())
|