spec-flow
Imported from alexei-led/cc-thingz/dist/pi/skills/spec-flow.
Install
npx skills add https://github.com/alexei-led/cc-thingz/tree/master/dist/pi/skills/spec-flow
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alexei-led-cc-thingz@llmmart
git clone https://github.com/alexei-led/cc-thingz.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alexei-led/cc-thingz collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Spec flow
Lightweight loop for controlled work, one task at a time: plan one slice, execute one task, checkpoint or close, repeat.
scripts/specctl (written specctl below) owns state. Do not edit task status
or .spec/SESSION.yaml by hand. references/specctl-commands.md lists every command.
references/method.md covers task quality, templates, the planning output, and
the mini-interview.
State model
.spec/tasks/TASK-*.md— executable vertical slices. Required for work..spec/epics/EPIC-*.md— optional group for multi-task plans..spec/reqs/REQ-*.md— optional WHY/WHAT context for ambiguous work..spec/SESSION.yaml— active task, step, base commit..spec/PROGRESS.md— append-only activity log.
Task states: todo, in-progress, done.
Modes
Orient
For status, the next task, resume, or health: run specctl status, ready,
session handoff, and validate. Report the active session, the next ready task,
validation issues, and the smallest next action.
Plan
For an idea, requirement, bug, or project gap that needs an executable plan.
Done when the smallest useful artifact set exists and passes specctl validate,
and its first task appears in specctl ready (a REQ-only plan has no ready task
yet). Create tasks and requirements with specctl new task|req, then fill in
the details; write an EPIC-* file by hand. Pick the smallest set:
- one clear slice: one
TASK-* - several slices: one
EPIC-*plus tasks - unclear WHY or WHAT: one
REQ-*first
Run specctl init when .spec/ is missing, and check status and session before
changing files. In an existing project, read the code and project instructions
first, and link REQ or EPIC context only when it reduces ambiguity. Ask
questions only when the slice is unclear. Show the proposed plan before writing it
unless the user already authorized that scope. Build a full backlog only on
request, and keep implementation code out of plan files.
Execute
For work, continue, or implement. Done when the relevant build/test/lint checks
pass on what you changed, or you name each check that did not run and why. Before
closing, confirm the acceptance criteria and show the scoped diff or
specctl session handoff; if the task cannot finish, checkpoint it instead.
- Check
specctl statusandspecctl session showfirst. Resume a matching session when the user asks to continue; ask before replacing a conflicting one. - Pick the task from
specctl readyor verify the named one withspecctl show, thenspecctl start TASK-<id>. - Share a short implementation plan unless that scope is already approved. Implement only this task; file follow-up tasks instead of widening scope.
- Take checks from the project instructions and the changed files; not every
project has
make.
Checkpoint or close
Checkpoint before stopping or switching context:
scripts/specctl checkpoint --message "<where to resume>"
Close a finished task:
scripts/specctl done TASK-<id> \
--summary "<what changed>" \
--tests "<checks passed, or not run: reason>" \
--files "<changed files or none>" \
--commits "<sha or none>"
--summary and --tests are required unless the user approves --force. They
record your evidence; specctl does not run or certify checks. Name the commands
and results, including skip reasons such as --tests "not run: docs-only task".
Authorization
Approval covers the agreed plan and implementation scope; do not ask again at each
mechanical step. Ask when scope changes, before using --force, or before
clearing a conflicting session.
Output
## Spec flow
Mode: orient | plan | execute | checkpoint | close
Task: <TASK-id or none>
Status: <ready | in-progress | checkpointed | done | blocked>
Evidence: <commands/tests/checks or skipped reason>
Next: <one command or action>
Failure handling
- No
.spec/: run or offerspecctl init. - No ready tasks: show blockers; plan new work or finish the blockers.
- Validation fails: fix the smallest artifact issue before work.
- Verification fails: fix within scope, checkpoint, or stop; do not mark done.
Files (cc-thingz)
-
references
-
method.md 2.4 KB
# Spec-flow method Create only the artifacts the work needs, not ceremony because the folders exist. ## Artifact quality A good task has: - one vertical slice - observable acceptance criteria - a verification command or manual check - clear blockers via `blocked-by` - meaningful file/scope notes, even if the exact file is still a likely path - out-of-scope notes when nearby work is tempting Avoid: - vague tasks like "improve auth" - layer-only tasks like "add database schema" unless independently useful - placeholder acceptance like "it works" - hidden dependencies not listed in `blocked-by` - file scope that says only `TBD` ## Minimal task template ```markdown --- id: TASK-<slug> status: todo priority: normal blocked-by: [] --- # <Task title> ## Description <one vertical slice> ## Acceptance - [ ] <observable behavior> - [ ] <verification command or manual check> ## Files - `path/or/TBD` — <expected change> ## Out of scope - <excluded adjacent work> ``` ## Epic file `specctl new` has no epic kind. Write `.spec/epics/EPIC-<slug>.md` by hand with frontmatter `id: EPIC-<slug>` and a `tasks:` list of existing task IDs; `specctl validate` rejects an epic with no tasks or a missing task. ## Planning output Show this before writing files: ```markdown ## Proposed plan Scope: <idea or REQ> Artifact set: <TASK only | EPIC + TASKs | REQ + EPIC + TASKs> ### Tasks 1. TASK-<slug> — <title> - Why: <one line> - Blocked by: [] | [TASK-x] - Acceptance: <2-4 observable checks> - Verification: <command/manual check> ### Open questions - none | <specific blocker> ``` ## Mini-interview Ask only what blocks a useful plan, at most 3-5 questions: the user-visible outcome, what is out of scope, what must stay unchanged, which data, API, or permission boundary matters, and how to verify success. For deep product discovery, keep notes in a `REQ-*` and plan later. ## Validation expectations `specctl validate` is intentionally stricter than a TODO list. A task should fail validation until it has meaningful Description, Acceptance, and Files sections. This keeps `spec-flow` from starting vague work. Draft tasks from `scripts/specctl new task` are allowed to be invalid until the planning step fills them in. ## Definition of ready A task is ready when: - status is `todo` - every `blocked-by` task is `done` - validation passes - no active session blocks switching -
specctl-commands.md 1.7 KB
# specctl commands Bundled CLI: `scripts/specctl`. ## Core loop - `scripts/specctl init` — create `.spec/` folders. - `scripts/specctl new task <slug|topic/slug>` — create a task template. - `scripts/specctl new req <slug|topic/slug>` — create a requirement template. - `scripts/specctl ready [--epic EPIC-x]` — list unblocked `todo` tasks. - `scripts/specctl start TASK-x` — reject unfinished or missing blockers, then set a task `in-progress` and open `SESSION.yaml`. Starting the active task again preserves its base commit and checkpoint state. `--force` replaces a conflicting session; it does not bypass dependencies. - `scripts/specctl checkpoint [--message "..."]` — append progress and print handoff. - `scripts/specctl session handoff` — print resume summary with git diff/status. - `scripts/specctl done TASK-x --summary ... --tests ... [--files ...] [--commits ...]` — close with evidence; see the evidence rule in SKILL.md. ## Inspect and repair - `scripts/specctl status [id] [--json]` — overview or one artifact summary. - `scripts/specctl show <REQ-x|EPIC-x|TASK-x>` — print artifact markdown. - `scripts/specctl validate` — check IDs, status, refs, cycles, and task quality. - `scripts/specctl reset TASK-x` — reset to `todo`; clears matching active session. - `scripts/specctl dep add TASK-b TASK-a` — make `TASK-b` wait for `TASK-a`. - `scripts/specctl dep add TASK-b TASK-a --type discovered-from` — add an informational discovery link. - `scripts/specctl dep rm TASK-b TASK-a` — remove a blocker. - `scripts/specctl dep list TASK-b` — list task links. - `scripts/specctl session show|resume|clear|step <name>` — inspect or update active session.
-
-
scripts
-
specctl 59 B · in bundle
-
specctl.py 32 KB
#!/usr/bin/env python3 """Tiny markdown-state CLI for spec-flow. Skills do planning and implementation. This CLI only owns durable state: artifacts, readiness, sessions, checkpoints, completion evidence, and validation. """ from __future__ import annotations import argparse import json import os import re import stat import subprocess import sys import tempfile from datetime import datetime, timedelta from datetime import timezone as _timezone from pathlib import Path from typing import Any, NoReturn try: from datetime import UTC except ImportError: # Python < 3.11 on some agent hosts. UTC = _timezone(timedelta(0)) SPEC_DIR = ".spec" TASKS_DIR = "tasks" REQS_DIR = "reqs" EPICS_DIR = "epics" MEMORY_DIR = "memory" PROGRESS_FILE = "PROGRESS.md" SESSION_FILE = "SESSION.yaml" TASK_PREFIX = "TASK-" REQ_PREFIX = "REQ-" EPIC_PREFIX = "EPIC-" TODO = "todo" IN_PROGRESS = "in-progress" DONE = "done" LEGACY_IN_PROGRESS = "in_progress" TASK_STATES = {TODO, IN_PROGRESS, DONE} SESSION_STEPS = {"planning", "implementing", "testing", "reviewing", "completing"} PRIORITY = {"critical": 0, "normal": 1, "low": 2} PLACEHOLDERS = { "tbd", "todo", "describe", "observable behavior is defined", "verification command or manual check is defined", "it works", "criterion is observable", } class Artifact(dict[str, Any]): """Small typed-ish dict for loaded markdown artifacts.""" # --- process and paths --- def fail(message: str, code: int = 1) -> NoReturn: print(f"Error: {message}", file=sys.stderr) sys.exit(code) def ok(message: str) -> None: print(f"✓ {message}") def root() -> Path: try: result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True, ) return Path(result.stdout.strip()) except (FileNotFoundError, subprocess.CalledProcessError): return Path.cwd() def spec_dir() -> Path: return root() / SPEC_DIR def require_spec() -> Path: path = spec_dir() if not path.exists(): fail(".spec/ not found. Run 'specctl init' first.") return path def now_iso() -> str: return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") def now_log() -> str: return datetime.now().strftime("%H:%M") def rel(path: Path) -> str: try: return str(path.relative_to(root())) except ValueError: return str(path) # --- markdown frontmatter --- def strip_comment(value: str) -> str: if value.startswith("[") and "]" in value: return value[: value.index("]") + 1].strip() return value.split(" #", 1)[0].strip() # Scalar values wrap in double quotes only when unquoted round-tripping would # lose data: a " #" sequence looks like a comment to strip_comment, and a # leading or trailing quote character is ambiguous with our own quoting (an # unquoted value ending in `"`, e.g. `Fixed the "bug"`, would otherwise be # corrupted by the plain-scalar parse path stripping the stray quote on the # next load). Everything else stays unquoted for backward compatibility with # existing frontmatter. _QUOTED_VALUE_RE = re.compile(r'^"((?:[^"\\]|\\.)*)"$') def needs_quoting(value: str) -> bool: return " #" in value or value.startswith(('"', "'")) or value.endswith(('"', "'")) def quote_scalar(value: str) -> str: if not needs_quoting(value): return value escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' def parse_quoted_scalar(value: str) -> str | None: match = _QUOTED_VALUE_RE.match(value) if not match: return None return re.sub(r"\\(.)", r"\1", match.group(1)) def strip_matched_quotes(value: str) -> str: """Strip a leading/trailing quote pair only when both ends match. A blind `.strip("\"'")` corrupts a value that merely starts or ends with an unmatched quote character that is part of the real content (e.g. `Fixed the "bug"`, which ends in `"` but was never wrapped by quote_scalar). Only unwrap when the value is actually bracketed by one quote type on both ends, which covers legacy single-quoted frontmatter. """ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": return value[1:-1] return value def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: if not text.startswith("---"): return {}, text.strip() parts = text.split("---", 2) if len(parts) < 3: return {}, text.strip() meta: dict[str, Any] = {} current_key: str | None = None current_list: list[str] = [] for raw in parts[1].strip().splitlines(): line = raw.rstrip() if not line or line.lstrip().startswith("#"): continue if line.startswith(" - "): if current_key: raw_item = line[4:].strip() quoted_item = parse_quoted_scalar(raw_item) current_list.append( quoted_item if quoted_item is not None else strip_comment(raw_item) ) continue if current_key is not None: meta[current_key] = current_list current_key = None current_list = [] if ":" not in line: continue key, _, raw_value = line.partition(":") raw_value = raw_value.strip() quoted = parse_quoted_scalar(raw_value) if quoted is not None: meta[key.strip()] = quoted continue value = strip_comment(raw_value) if not value: current_key = key.strip() elif value.startswith("[") and value.endswith("]"): meta[key.strip()] = [ item.strip().strip("\"'") for item in value[1:-1].split(",") if item.strip() ] else: meta[key.strip()] = strip_matched_quotes(value) if current_key is not None: meta[current_key] = current_list return meta, parts[2].strip() def dump_frontmatter(meta: dict[str, Any], body: str) -> str: lines = ["---"] for key, value in meta.items(): if isinstance(value, list): if value: lines.append(f"{key}:") lines.extend(f" - {quote_scalar(str(item))}" for item in value) else: lines.append(f"{key}: []") else: lines.append(f"{key}: {quote_scalar(str(value))}") lines.extend(["---", "", body.strip()]) return "\n".join(lines) + "\n" def listify(value: Any) -> list[str]: if value in (None, ""): return [] if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] if isinstance(value, str): value = strip_comment(value.strip()) if value.startswith("[") and value.endswith("]"): return [ item.strip().strip("\"'") for item in value[1:-1].split(",") if item ] return [value] if value else [] return [str(value)] def csv(value: str | None) -> list[str]: if not value: return [] return [item.strip() for item in value.split(",") if item.strip()] # --- artifacts --- def normalize_id(value: str, prefix: str) -> str: value = value.strip() return value if value.startswith(prefix) else f"{prefix}{value}" def task_id(value: str) -> str: return normalize_id(value, TASK_PREFIX) def req_id(value: str) -> str: return normalize_id(value, REQ_PREFIX) def epic_id(value: str) -> str: return normalize_id(value, EPIC_PREFIX) def slug(value: str) -> str: result = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") if not result: fail("name must contain at least one alphanumeric character") return result def split_name(value: str) -> tuple[list[str], str]: parts = [slug(part) for part in value.strip("/").split("/") if part.strip()] if not parts: fail("name is required") return parts[:-1], parts[-1] def title_from_body(body: str) -> str: for line in body.splitlines(): if line.startswith("#"): return line.lstrip("# ").strip() or "Untitled" return "Untitled" def load(path: Path) -> Artifact: meta, body = parse_frontmatter(path.read_text(encoding="utf-8")) return Artifact(path=path, meta=meta, body=body, id=meta.get("id", path.stem)) def paths(base: Path, subdir: str, pattern: str) -> list[Path]: folder = base / subdir return sorted(folder.rglob(pattern)) if folder.exists() else [] def artifacts(base: Path, kind: str) -> list[Artifact]: if kind == "task": return [load(path) for path in paths(base, TASKS_DIR, "TASK-*.md")] if kind == "req": return [load(path) for path in paths(base, REQS_DIR, "REQ-*.md")] if kind == "epic": return [load(path) for path in paths(base, EPICS_DIR, "EPIC-*.md")] fail(f"unknown artifact kind: {kind}") def find(base: Path, item_id: str) -> Artifact | None: candidates = [ task_id(item_id), req_id(item_id), epic_id(item_id), item_id, ] for kind in ("task", "req", "epic"): for item in artifacts(base, kind): if item["id"] in candidates or item["path"].stem in candidates: return item return None def status_of(item: Artifact) -> str: status = item["meta"].get("status", TODO) return IN_PROGRESS if status == LEGACY_IN_PROGRESS else str(status) def _default_create_mode() -> int: """Return the umask-respecting default mode for a newly created file.""" umask = os.umask(0) os.umask(umask) return 0o666 & ~umask def atomic_write(path: Path, content: str, *, encoding: str = "utf-8") -> None: """Write content to path via temp-file + os.replace so a process killed mid-write leaves the original file intact instead of truncated/corrupt. os.replace() is atomic within a filesystem on POSIX and Windows. mkstemp() creates the temp file mode 0600 regardless of the target's mode, and os.replace() carries that mode into the target — so a plain temp-file swap silently tightens every saved file to owner-only. Preserve the target's existing mode when it already exists; fall back to the umask-respecting default for brand-new files. Ceiling: this only closes the interrupt-corruption window for a single write. It adds no file locking, so two concurrent specctl invocations can still race read-modify-write and one can silently drop the other's change. specctl assumes a single agent process; upgrade to locking if concurrent invocations against the same .spec/ become a real scenario. """ mode = ( stat.S_IMODE(path.stat().st_mode) if path.exists() else _default_create_mode() ) fd, tmp_name = tempfile.mkstemp( dir=path.parent, prefix=f".{path.name}.", suffix=".tmp" ) try: os.fchmod(fd, mode) with os.fdopen(fd, "w", encoding=encoding) as handle: handle.write(content) os.replace(tmp_name, path) except BaseException: Path(tmp_name).unlink(missing_ok=True) raise def save(item: Artifact) -> None: atomic_write(item["path"], dump_frontmatter(item["meta"], item["body"])) # --- state helpers --- def init_dirs() -> Path: base = spec_dir() for name in (TASKS_DIR, REQS_DIR, EPICS_DIR, MEMORY_DIR): (base / name).mkdir(parents=True, exist_ok=True) progress = base / PROGRESS_FILE if not progress.exists(): progress.write_text(f"{now_log()} INIT .spec/\n", encoding="utf-8") return base def log(action: str, target: str) -> None: # True append instead of read-modify-write-whole-file: a kill mid-write # can now only truncate the newest line, not destroy prior history. # Ceiling: relies on the OS applying one short O_APPEND write atomically # (true for single log lines on POSIX); upgrade to locking if concurrent # specctl processes append to the same PROGRESS.md often enough for # interleaved lines to matter. base = spec_dir() base.mkdir(parents=True, exist_ok=True) progress = base / PROGRESS_FILE with progress.open("a", encoding="utf-8") as handle: handle.write(f"{now_log()} {action} {target}\n") def session_path() -> Path: return spec_dir() / SESSION_FILE def read_session() -> dict[str, str]: path = session_path() if not path.exists(): return {} session: dict[str, str] = {} for line in path.read_text(encoding="utf-8").splitlines(): if not line or line.startswith("#") or ":" not in line: continue key, _, value = line.partition(":") session[key.strip()] = value.strip() return session def write_session(session: dict[str, str]) -> None: lines = ["# Session state - auto-managed by specctl"] lines.extend(f"{key}: {value}" for key, value in session.items()) atomic_write(session_path(), "\n".join(lines) + "\n") def clear_session() -> None: path = session_path() if path.exists(): path.unlink() def git(args: list[str]) -> str: try: result = subprocess.run(args, capture_output=True, text=True, check=True) return result.stdout.strip() except (FileNotFoundError, subprocess.CalledProcessError): return "" def head() -> str: return git(["git", "rev-parse", "--short", "HEAD"]) def handoff() -> dict[str, Any]: session = read_session() base = session.get("base_commit", "") ready_ids = [item["id"] for item in ready_tasks(require_spec())[:5]] return { "task": session.get("task", ""), "step": session.get("step", ""), "base_commit": base, "diff_stat": git(["git", "diff", "--stat", base]) if base else "", "git_status": git(["git", "status", "--short"]), "ready": ready_ids, } def print_handoff(data: dict[str, Any]) -> None: print("--- SESSION HANDOFF ---") if data["task"]: print(f"Task: {data['task']} (step: {data['step']})") else: print("Task: none") if data["diff_stat"]: print(f"Changes since base:\n{data['diff_stat']}") elif data["git_status"]: print(f"Uncommitted changes:\n{data['git_status']}") else: print("Changes: none") print(f"Next ready: {', '.join(data['ready']) if data['ready'] else 'none'}") if data["task"]: print(f"Resume: spec-flow work {data['task']}") print("---") # --- readiness and validation --- def ready_tasks(base: Path, epic: str | None = None) -> list[Artifact]: tasks = artifacts(base, "task") done = {item["id"] for item in tasks if status_of(item) == DONE} ready: list[Artifact] = [] for item in tasks: meta = item["meta"] if epic and meta.get("epic") != epic_id(epic): continue if status_of(item) != TODO: continue if all(dep in done for dep in listify(meta.get("blocked-by"))): ready.append(item) return sorted( ready, key=lambda item: PRIORITY.get(item["meta"].get("priority", "normal"), 1) ) def blocked_tasks( base: Path, epic: str | None = None ) -> list[tuple[Artifact, list[str]]]: tasks = artifacts(base, "task") done = {item["id"] for item in tasks if status_of(item) == DONE} blocked: list[tuple[Artifact, list[str]]] = [] for item in tasks: if epic and item["meta"].get("epic") != epic_id(epic): continue if status_of(item) != TODO: continue missing = [ dep for dep in listify(item["meta"].get("blocked-by")) if dep not in done ] if missing: blocked.append((item, missing)) return blocked def section(body: str, name: str) -> str: lines = body.splitlines() capture = False found: list[str] = [] wanted = f"## {name}".lower() for line in lines: if line.lower().strip() == wanted: capture = True continue if capture and line.startswith("## "): break if capture: found.append(line) return "\n".join(found).strip() def is_placeholder(line: str) -> bool: line = line.lower().strip(" -[]0123456789.`") return line in PLACEHOLDERS or line.startswith("describe ") def has_real_text(text: str) -> bool: lines = [line.strip() for line in text.splitlines() if line.strip()] return any(len(line) > 12 and not is_placeholder(line) for line in lines) def creates_cycle(base: Path, item_id: str, dep_id: str) -> bool: task_by_id = {item["id"]: item for item in artifacts(base, "task")} def reaches(current: str, seen: set[str]) -> bool: if current == item_id: return True if current in seen: return False seen.add(current) item = task_by_id.get(current) if not item: return False return any( reaches(dep, seen) for dep in listify(item["meta"].get("blocked-by")) ) return dep_id == item_id or reaches(dep_id, set()) def validate() -> list[str]: base = require_spec() issues: list[str] = [] tasks = artifacts(base, "task") reqs = artifacts(base, "req") epics = artifacts(base, "epic") for label, items in (("task", tasks), ("req", reqs), ("epic", epics)): seen: dict[str, Path] = {} for item in items: item_id = item["id"] if item_id in seen: issues.append( f"duplicate {label} id {item_id}: " f"{rel(seen[item_id])} and {rel(item['path'])}" ) seen[item_id] = item["path"] task_ids = {item["id"] for item in tasks} req_ids = {item["id"] for item in reqs} for item in tasks: meta = item["meta"] item_id = item["id"] if status_of(item) not in TASK_STATES: issues.append(f"{item_id}: invalid status '{meta.get('status')}'") if meta.get("status") == LEGACY_IN_PROGRESS: issues.append( f"{item_id}: use status '{IN_PROGRESS}', not '{LEGACY_IN_PROGRESS}'" ) if not has_real_text(section(item["body"], "Description")): issues.append(f"{item_id}: missing meaningful Description") if not has_real_text(section(item["body"], "Acceptance")): issues.append(f"{item_id}: missing meaningful Acceptance") if not has_real_text(section(item["body"], "Files")): issues.append(f"{item_id}: missing meaningful Files") for dep in listify(meta.get("blocked-by")): if dep not in task_ids: issues.append(f"{item_id}: blocked-by references missing task '{dep}'") for req in listify(meta.get("implements")): if req not in req_ids: issues.append( f"{item_id}: implements references missing requirement '{req}'" ) for req in reqs: if not has_real_text(section(req["body"], "Success criteria")): issues.append(f"{req['id']}: missing meaningful Success criteria") for epic in epics: refs = listify(epic["meta"].get("tasks")) if not refs: issues.append(f"{epic['id']}: no tasks listed") for ref in refs: if ref not in task_ids: issues.append(f"{epic['id']}: references missing task '{ref}'") task_by_id = {item["id"]: item for item in tasks} def cyclic(item_id: str, visited: set[str], stack: set[str]) -> bool: if item_id in stack: return True if item_id in visited: return False visited.add(item_id) stack.add(item_id) item = task_by_id.get(item_id) if item: for dep in listify(item["meta"].get("blocked-by")): if cyclic(dep, visited, stack): return True stack.remove(item_id) return False if any(cyclic(item["id"], set(), set()) for item in tasks): issues.append("dependency cycle detected") return issues # --- commands --- def cmd_init(_args: argparse.Namespace) -> None: existed = spec_dir().exists() base = init_dirs() print( f".spec/ already exists at {base}" if existed else f"✓ Created .spec/ at {base}" ) def task_template(item_id: str, title: str) -> str: return f"""--- id: {item_id} status: todo priority: normal blocked-by: [] --- # {title} ## Description TBD ## Acceptance - [ ] TBD ## Files - TBD ## Out of scope - TBD """ def req_template(item_id: str, title: str) -> str: return f"""--- id: {item_id} version: 1 priority: normal --- # {title} ## Problem TBD ## Success criteria - [ ] TBD ## Out of scope - TBD """ def cmd_new(args: argparse.Namespace) -> None: base = init_dirs() folders, name = split_name(args.name) title = name.replace("-", " ").replace("_", " ").title() if args.kind == "task": item_id = task_id(name) target = base / TASKS_DIR / Path(*folders) / f"{item_id}.md" content = task_template(item_id, title) else: item_id = req_id(name) target = base / REQS_DIR / Path(*folders) / f"{item_id}.md" content = req_template(item_id, title) if find(base, item_id) or target.exists(): fail(f"artifact already exists: {item_id}") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") log("NEW", item_id) ok(f"Created {rel(target)}") def cmd_status(args: argparse.Namespace) -> None: base = require_spec() if args.id: item = find(base, args.id) if not item: fail(f"Not found: {args.id}") meta = item["meta"] print(f"ID: {item['id']}") print(f"Path: {rel(item['path'])}") if str(item["id"]).startswith(TASK_PREFIX): print(f"Status: {status_of(item)}") print(f"Priority: {meta.get('priority', 'normal')}") print(f"Blocked by: {', '.join(listify(meta.get('blocked-by'))) or 'none'}") return tasks = artifacts(base, "task") counts = { state: sum(1 for item in tasks if status_of(item) == state) for state in TASK_STATES } ready = ready_tasks(base) session = read_session() if args.json: print( json.dumps( { "total": len(tasks), "todo": counts[TODO], "in_progress": counts[IN_PROGRESS], "done": counts[DONE], "ready": [item["id"] for item in ready[:5]], "session": session, } ) ) return print("SPEC STATUS") print(f"Tasks: {counts[DONE]}/{len(tasks)} done, {counts[IN_PROGRESS]} in progress") if session: print(f"Active session: {session.get('task')} ({session.get('step')})") if ready: print("Ready:") for item in ready[:5]: print(f" • {item['id']}") else: print("Ready: none") def cmd_show(args: argparse.Namespace) -> None: item = find(require_spec(), args.id) if not item: fail(f"Not found: {args.id}") print(item["path"].read_text(encoding="utf-8")) def cmd_ready(args: argparse.Namespace) -> None: base = require_spec() ready = ready_tasks(base, args.epic) if args.json: print( json.dumps( [ {"id": item["id"], "title": title_from_body(item["body"])} for item in ready ] ) ) return if ready: for item in ready: print(f"{item['id']} {title_from_body(item['body'])}") return print("No tasks ready to start.") for item, deps in blocked_tasks(base, args.epic): print(f" blocked: {item['id']} waiting for {', '.join(deps)}") def cmd_start(args: argparse.Namespace) -> None: base = require_spec() item = find(base, task_id(args.id)) if not item or not str(item["id"]).startswith(TASK_PREFIX): fail(f"Task not found: {task_id(args.id)}") if status_of(item) == DONE: fail(f"Task {item['id']} is already done") session = read_session() active = session.get("task") if active and active != item["id"] and not args.force: fail(f"Session exists for {active}. Run 'specctl session handoff' first.") done = {task["id"] for task in artifacts(base, "task") if status_of(task) == DONE} blockers = [ dep for dep in listify(item["meta"].get("blocked-by")) if dep not in done ] if blockers: fail(f"Task {item['id']} is blocked by: {', '.join(blockers)}") if active == item["id"] and status_of(item) == IN_PROGRESS: ok(f"Resumed {item['id']}") return item["meta"]["status"] = IN_PROGRESS save(item) write_session( { "task": item["id"], "step": "planning", "started": now_iso(), "base_commit": head(), } ) log("START", item["id"]) ok(f"Started {item['id']}") def cmd_done(args: argparse.Namespace) -> None: base = require_spec() item = find(base, task_id(args.id)) if not item or not str(item["id"]).startswith(TASK_PREFIX): fail(f"Task not found: {task_id(args.id)}") session = read_session() active = session.get("task") if active and active != item["id"] and not args.force: fail(f"Active session is for {active}, not {item['id']}") missing = [ flag for flag, value in (("--summary", args.summary), ("--tests", args.tests)) if not value ] if missing and not args.force: fail(f"Missing completion evidence: {', '.join(missing)}") item["meta"].update({"status": DONE, "done-at": now_iso()}) for key, value in { "done-summary": args.summary, "done-tests": args.tests, "done-files": csv(args.files), "done-commits": csv(args.commits), }.items(): if value: item["meta"][key] = value save(item) if active == item["id"]: clear_session() log("DONE", item["id"]) ok(f"Completed {item['id']}") def cmd_checkpoint(args: argparse.Namespace) -> None: require_spec() session = read_session() target = session.get("task", "session") if session else "session" if args.message: target = f"{target}: {args.message}" log("CHECKPOINT", target) data = handoff() print(json.dumps(data) if args.json else "", end="") if not args.json: print_handoff(data) def print_session(data: dict[str, str]) -> None: if not data: print("No active session") return print("Active Session:") print(f" Task: {data.get('task', 'unknown')}") print(f" Step: {data.get('step', 'unknown')}") if data.get("base_commit"): print(f" Base commit: {data['base_commit']}") def cmd_session(args: argparse.Namespace) -> None: require_spec() if args.action == "show": data = read_session() if args.json: print(json.dumps(data)) else: print_session(data) elif args.action == "clear": data = read_session() clear_session() print( f"Cleared session for {data.get('task')}" if data else "No active session" ) elif args.action == "handoff": data = handoff() print(json.dumps(data) if args.json else "", end="") if not args.json: print_handoff(data) elif args.action == "resume": data = read_session() if args.json: print(json.dumps(data)) elif data: print_session(data) print(f"Resume: spec-flow work {data.get('task')}") else: print("No session to resume") elif args.action == "step": data = read_session() if not data: fail("No active session") data["step"] = args.step write_session(data) ok(f"Session step: {args.step}") def cmd_validate(_args: argparse.Namespace) -> None: issues = validate() if issues: print("Validation issues found:") for issue in issues: print(f" • {issue}") sys.exit(1) ok("No issues found") def cmd_reset(args: argparse.Namespace) -> None: item = find(require_spec(), task_id(args.id)) if not item: fail(f"Task not found: {task_id(args.id)}") item["meta"] = { key: value for key, value in item["meta"].items() if not key.startswith("done-") } item["meta"]["status"] = TODO save(item) if read_session().get("task") == item["id"]: clear_session() log("RESET", item["id"]) ok(f"Reset {item['id']} to todo") def cmd_dep(args: argparse.Namespace) -> None: base = require_spec() item = find(base, task_id(args.task)) if not item: fail(f"Task not found: {task_id(args.task)}") dep = task_id(getattr(args, "dep", "")) if getattr(args, "dep", "") else "" if args.action == "list": blockers = listify(item["meta"].get("blocked-by")) discovered = listify(item["meta"].get("discovered-from")) print(json.dumps({"blocked-by": blockers, "discovered-from": discovered})) return if not find(base, dep): fail(f"Dependency not found: {dep}") key = "discovered-from" if args.type == "discovered-from" else "blocked-by" values = listify(item["meta"].get(key)) if args.action == "add" and dep not in values: if key == "blocked-by" and creates_cycle(base, item["id"], dep): fail(f"Cannot add dependency: {item['id']} -> {dep} creates a cycle") values.append(dep) if args.action == "rm" and dep in values: values.remove(dep) item["meta"][key] = values save(item) ok(f"Updated {item['id']} {key}") # --- parser --- def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="specctl - lightweight spec-flow state CLI") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("init") new = sub.add_parser("new") new.add_argument("kind", choices=["task", "req"]) new.add_argument("name") status = sub.add_parser("status") status.add_argument("id", nargs="?") status.add_argument("--json", action="store_true") show = sub.add_parser("show") show.add_argument("id") ready = sub.add_parser("ready") ready.add_argument("--epic") ready.add_argument("--json", action="store_true") start = sub.add_parser("start") start.add_argument("id") start.add_argument("--force", action="store_true") done = sub.add_parser("done") done.add_argument("id") done.add_argument("--summary") done.add_argument("--tests") done.add_argument("--files") done.add_argument("--commits") done.add_argument("--force", action="store_true") checkpoint = sub.add_parser("checkpoint") checkpoint.add_argument("--message") checkpoint.add_argument("--json", action="store_true") session = sub.add_parser("session") session_sub = session.add_subparsers(dest="action", required=True) for name in ("show", "handoff", "resume"): cmd = session_sub.add_parser(name) cmd.add_argument("--json", action="store_true") session_sub.add_parser("clear") step = session_sub.add_parser("step") step.add_argument("step", choices=sorted(SESSION_STEPS)) sub.add_parser("validate") reset = sub.add_parser("reset") reset.add_argument("id") dep = sub.add_parser("dep") dep_sub = dep.add_subparsers(dest="action", required=True) add = dep_sub.add_parser("add") add.add_argument("task") add.add_argument("dep") add.add_argument("--type", choices=["blocks", "discovered-from"], default="blocks") rm = dep_sub.add_parser("rm") rm.add_argument("task") rm.add_argument("dep") rm.add_argument("--type", choices=["blocks", "discovered-from"], default="blocks") listing = dep_sub.add_parser("list") listing.add_argument("task") return p def main() -> None: args = parser().parse_args() commands = { "init": cmd_init, "new": cmd_new, "status": cmd_status, "show": cmd_show, "ready": cmd_ready, "start": cmd_start, "done": cmd_done, "checkpoint": cmd_checkpoint, "session": cmd_session, "validate": cmd_validate, "reset": cmd_reset, "dep": cmd_dep, } commands[args.cmd](args) if __name__ == "__main__": main()
-
-
SKILL.md 4.8 KB
--- {"description":"Use when planning, executing, checkpointing, finishing, or inspecting lightweight spec-driven work. Runs one task at a time using `.spec/` markdown files and the bundled `specctl` helper. NOT for broad product discovery beyond a short requirement interview. NOT for generic implementation planning that does not read or write `.spec/` files.","name":"spec-flow"} --- <!-- Pi platform guidance --> <!-- Use installed Pi tool names exactly, including extension toolsets such as Task*, Monitor*, and Loop*. --> <!-- When available, track work with Task* (`todo` is the fallback), run long or background commands with MonitorCreate, and schedule follow-up with LoopCreate instead of sleep/poll loops. --> # Spec flow Lightweight loop for controlled work, one task at a time: plan one slice, execute one task, checkpoint or close, repeat. `scripts/specctl` (written `specctl` below) owns state. Do not edit task status or `.spec/SESSION.yaml` by hand. `references/specctl-commands.md` lists every command. `references/method.md` covers task quality, templates, the planning output, and the mini-interview. ## State model - `.spec/tasks/TASK-*.md` — executable vertical slices. Required for work. - `.spec/epics/EPIC-*.md` — optional group for multi-task plans. - `.spec/reqs/REQ-*.md` — optional WHY/WHAT context for ambiguous work. - `.spec/SESSION.yaml` — active task, step, base commit. - `.spec/PROGRESS.md` — append-only activity log. Task states: `todo`, `in-progress`, `done`. ## Modes ### Orient For status, the next task, resume, or health: run `specctl status`, `ready`, `session handoff`, and `validate`. Report the active session, the next ready task, validation issues, and the smallest next action. ### Plan For an idea, requirement, bug, or project gap that needs an executable plan. Done when the smallest useful artifact set exists and passes `specctl validate`, and its first task appears in `specctl ready` (a REQ-only plan has no ready task yet). Create tasks and requirements with `specctl new task|req`, then fill in the details; write an `EPIC-*` file by hand. Pick the smallest set: - one clear slice: one `TASK-*` - several slices: one `EPIC-*` plus tasks - unclear WHY or WHAT: one `REQ-*` first Run `specctl init` when `.spec/` is missing, and check status and session before changing files. In an existing project, read the code and project instructions first, and link REQ or EPIC context only when it reduces ambiguity. Ask questions only when the slice is unclear. Show the proposed plan before writing it unless the user already authorized that scope. Build a full backlog only on request, and keep implementation code out of plan files. ### Execute For work, continue, or implement. Done when the relevant build/test/lint checks pass on what you changed, or you name each check that did not run and why. Before closing, confirm the acceptance criteria and show the scoped diff or `specctl session handoff`; if the task cannot finish, checkpoint it instead. - Check `specctl status` and `specctl session show` first. Resume a matching session when the user asks to continue; ask before replacing a conflicting one. - Pick the task from `specctl ready` or verify the named one with `specctl show`, then `specctl start TASK-<id>`. - Share a short implementation plan unless that scope is already approved. Implement only this task; file follow-up tasks instead of widening scope. - Take checks from the project instructions and the changed files; not every project has `make`. ### Checkpoint or close Checkpoint before stopping or switching context: ```bash scripts/specctl checkpoint --message "<where to resume>" ``` Close a finished task: ```bash scripts/specctl done TASK-<id> \ --summary "<what changed>" \ --tests "<checks passed, or not run: reason>" \ --files "<changed files or none>" \ --commits "<sha or none>" ``` `--summary` and `--tests` are required unless the user approves `--force`. They record your evidence; `specctl` does not run or certify checks. Name the commands and results, including skip reasons such as `--tests "not run: docs-only task"`. ## Authorization Approval covers the agreed plan and implementation scope; do not ask again at each mechanical step. Ask when scope changes, before using `--force`, or before clearing a conflicting session. ## Output ```markdown ## Spec flow Mode: orient | plan | execute | checkpoint | close Task: <TASK-id or none> Status: <ready | in-progress | checkpointed | done | blocked> Evidence: <commands/tests/checks or skipped reason> Next: <one command or action> ``` ## Failure handling - No `.spec/`: run or offer `specctl init`. - No ready tasks: show blockers; plan new work or finish the blockers. - Validation fails: fix the smallest artifact issue before work. - Verification fails: fix within scope, checkpoint, or stop; do not mark done.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.