Initial commit
This commit is contained in:
+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())
|
||||
Reference in New Issue
Block a user