CtrlK
BlogDocsLog inGet started
Tessl Logo

spec-driven-development/spec-as-source

Spec-driven development on OpenSpec, with mechanical spec-as-source enforcement: a custom 'spec-as-source' OpenSpec schema adds file-ownership (targets) and test-verification ([@test]) metadata to every capability spec, three scripts (link check, ownership check, manifest build) keep code and specs from drifting apart, plus requirement-gathering, spec-writer, work-review, and a session-handoff skill with a proactive context-warning hook.

68

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

build_router.pyskills/skill-router/

#!/usr/bin/env python3
# GENERATED FROM SPEC — DO NOT EDIT DIRECTLY
# Source: openspec/specs/skill-router/spec.md
"""Build the routing table the skill-router decides on.

Reads every skill's frontmatter from disk with a real YAML parser and emits
skills/skill-router/routing-table.md: a compact index plus the complete
descriptions, which are the routing contract.

Reports divergences instead of reconciling them: skills on disk but not
declared in plugin.json (and the reverse), shadow copies that have drifted,
and skill names referenced in a body but present nowhere.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path

import yaml

# Phase and execution mode per skill. Kept here rather than in the skills'
# frontmatter so that adding a skill cannot silently give it a phase.
#
# mode=inline is NOT a performance choice, it is a correctness one: a skill
# whose input is the current conversation cannot run in a fresh-context
# subagent, which cannot see that conversation. Routing `handoff` to a
# subagent yields a well-formed, empty snapshot — a failure that does not
# announce itself. The criterion is who the interlocutor is, never length.
CLASSIFICATION = {
    "spec-as-source-setup":    ("0-setup",   "subagent"),
    "plan-mode":               ("1-plan",    "inline"),
    "plan-judge":              ("—",         "self"),
    "prompt-loop":             ("2-intake",  "inline"),
    "requirement-gathering":   ("2-intake",  "inline"),
    "openspec-explore":        ("3-explore", "inline"),
    "openspec-propose":        ("4-propose", "subagent"),
    "spec-writer":             ("5-spec",    "inline"),
    "openspec-apply-change":   ("6-apply",   "subagent"),
    "spec-loop":               ("6-apply",   "subagent"),
    "spec-verify":             ("7-verify",  "subagent"),
    "work-review":             ("8-review",  "inline"),
    "openspec-sync-specs":     ("9-close",   "subagent"),
    "openspec-archive-change": ("9-close",   "subagent"),
    "spec-ci-sync":            ("maint",     "subagent"),
    "spec-rebuild":            ("maint",     "subagent"),
    "handoff":                 ("any",       "inline"),
    "handoff-skill":           ("any",       "inline"),
    "skill-router":            ("—",         "self"),
}

PRECONDITION = {
    "spec-as-source-setup":    "`scripts/verify.sh` or `openspec/schemas/` missing",
    "plan-mode":               "`openspec/PLAN.md` missing, or the plan gate exits non-zero",
    "plan-judge":              "delegated only by `plan-mode` for a fully draft plan",
    "prompt-loop":             "applicability gate passed AND the work is non-trivial",
    "requirement-gathering":   "request still vague after the loop, or the loop was skipped",
    "openspec-explore":        "no active change for this idea yet",
    "openspec-propose":        "`check-plan-gate.sh --change <n>` exits 0 AND no active change of that name",
    "spec-writer":             "`openspec/specs/<cap>/spec.md` missing or malformed",
    "openspec-apply-change":   "the active change has `tasks.md` with at least one unchecked `- [ ]`",
    "spec-loop":               "as above, AND the user asked for unattended execution",
    "spec-verify":             "`scripts/verify.sh` exists and is executable",
    "work-review":             "verification green AND zero unchecked tasks",
    "openspec-sync-specs":     "the change has delta specs not yet in `openspec/specs/`",
    "openspec-archive-change": "zero unchecked tasks AND deltas synced",
    "spec-ci-sync":            "the `[@test]` set differs from the CI workflow",
    "spec-rebuild":            "destructive — ROUTER.md requires explicit confirmation first",
    "handoff":                 "input is the current conversation — subagent impossible",
    "handoff-skill":           "input is the current conversation — subagent impossible",
    "skill-router":            "not a routing destination",
}

# A dangling reference is a name used *as a skill* that exists nowhere. Matching
# every backticked identifier would flag rules and schemas too, so the patterns
# below key on invocation context — and the real case in this repo,
# "suggest using openspec-continue-change", carries no backticks at all.
INVOCATION_RES = (
    re.compile(r"(?:use|using|invoke|run|call|delegate to|hand(?:ed)?\s+(?:it\s+)?"
               r"(?:off\s+)?to)\s+`?(?P<name>[a-z][a-z0-9-]*-[a-z0-9-]+)`?(?![/.\w])",
               re.IGNORECASE),
    re.compile(r"`(?P<name>[a-z][a-z0-9-]*-[a-z0-9-]+)`\s+skill"),
    re.compile(r"(?<![\w/.-])skills/(?P<name>[a-z][a-z0-9-]*-[a-z0-9-]+)(?![/.\w])"),
)


def die(message):
    print(f"build_router: {message}", file=sys.stderr)
    sys.exit(1)


def read_skill(skill_dir):
    """Extract name and description, failing loudly on anything malformed.

    A description truncated at its first line loses the trigger phrases, which
    are the exact signal routing rests on — so this uses a real YAML parser and
    never a line-oriented grep. PyYAML ignores the `# GENERATED FROM SPEC`
    comment lines some frontmatter carries; a line parser would not.
    """
    md = skill_dir / "SKILL.md"
    if not md.is_file():
        die(f"{skill_dir.name}: no SKILL.md")
    text = md.read_text(encoding="utf-8")
    if not text.startswith("---"):
        die(f"{skill_dir.name}: no YAML frontmatter")
    parts = text.split("---", 2)
    if len(parts) < 3:
        die(f"{skill_dir.name}: unterminated frontmatter")
    try:
        meta = yaml.safe_load(parts[1])
    except yaml.YAMLError as exc:
        die(f"{skill_dir.name}: unparsable frontmatter — {exc}")
    if not isinstance(meta, dict):
        die(f"{skill_dir.name}: frontmatter is not a mapping")
    for field in ("name", "description"):
        if not meta.get(field):
            die(f"{skill_dir.name}: frontmatter has no {field}")
    return {
        "name": str(meta["name"]).strip(),
        "description": str(meta["description"]).strip(),
        "path": skill_dir,
        "body": parts[2],
    }


def scan(root):
    skills_dir = root / "skills"
    if not skills_dir.is_dir():
        die(f"no skills/ directory under {root}")
    found = [read_skill(d) for d in sorted(skills_dir.iterdir())
             if d.is_dir() and not d.name.startswith(".")]
    if not found:
        die(f"no skills found under {skills_dir}")
    return found


def declared_in_plugin(root):
    manifest = root / ".tessl-plugin" / "plugin.json"
    if not manifest.is_file():
        return None
    try:
        data = json.loads(manifest.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        die(f"plugin.json is not valid JSON — {exc}")
    return {Path(p).name for p in data.get("skills", [])}


def divergences(root, skills):
    """Report, never reconcile. A skill on disk but undeclared will not ship;
    a declared skill that is absent is a broken package. Different bugs."""
    out = []
    on_disk = {s["path"].name for s in skills}
    declared = declared_in_plugin(root)
    if declared is None:
        out.append(("no-manifest", "no .tessl-plugin/plugin.json — nothing to compare against"))
    else:
        for name in sorted(on_disk - declared):
            out.append(("undeclared", f"`{name}` exists in skills/ but is not in plugin.json"))
        for name in sorted(declared - on_disk):
            out.append(("missing", f"plugin.json declares `{name}` but it is not on disk"))

    # Shadow copies. Counted once (the canonical copy is always the one under
    # <root>/skills/); reported with whether they have drifted.
    canonical = {s["path"].name: (s["path"] / "SKILL.md") for s in skills}
    for label, shadow_root in (("shadow-in-repo", root / ".claude" / "skills"),
                               ("shadow-global", Path.home() / ".claude" / "skills")):
        if not shadow_root.is_dir():
            continue
        for d in sorted(shadow_root.iterdir()):
            if not d.is_dir() or d.name not in canonical:
                continue
            shadow_md = d / "SKILL.md"
            if not shadow_md.is_file():
                continue
            # A symlink resolving to the canonical copy is not a second source
            # of truth — it is the same one, which is the whole point of
            # installing by link instead of by copy. Reporting it would bury
            # the real divergences in noise.
            try:
                if shadow_md.resolve() == canonical[d.name].resolve():
                    continue
            except OSError:
                pass
            try:
                same = shadow_md.read_text(encoding="utf-8") == \
                       canonical[d.name].read_text(encoding="utf-8")
            except OSError:
                same = False
            verdict = "identical" if same else "CONTENT DIFFERS"
            out.append((label, f"`{d.name}` also at {shadow_md} — {verdict}"))
    return out


def strip_code_blocks(text):
    """Drop fenced code blocks before looking for skill references.

    A name inside a fence is example code, not prose invoking a skill:
    `skills/spec-ci-sync/SKILL.md` embeds a CI workflow whose YAML reads
    `- name: Run spec-linked tests`, which "run X" would otherwise read as a
    call to a skill named spec-linked.
    """
    out, fenced = [], False
    for line in text.splitlines():
        if line.lstrip().startswith("```"):
            fenced = not fenced
            continue
        if not fenced:
            out.append(line)
    return "\n".join(out)


def non_skill_names(root):
    """Names that legitimately appear in bodies and are not skills.

    Read from disk rather than hard-coded: rules and schemas are cited by name
    all over the skill bodies, and flagging them as dangling would bury the one
    reference that actually is.
    """
    names = set()
    rules_dir = root / "rules"
    if rules_dir.is_dir():
        names |= {p.stem for p in rules_dir.glob("*.md")}
    schemas_dir = root / "openspec" / "schemas"
    if schemas_dir.is_dir():
        names |= {p.name for p in schemas_dir.iterdir() if p.is_dir()}
    # OpenSpec's own upstream schema, referenced by spec-as-source-setup as the
    # thing this plugin's schema was forked from. Not a skill, not on disk.
    names.add("spec-driven")
    return names


def dangling(root, skills):
    """Skill names cited in a body as skills, that exist nowhere.

    `skills/openspec-apply-change/SKILL.md:51` says "suggest using
    openspec-continue-change", which does not exist. A router that trusts that
    sentence invents a destination.
    """
    known = {s["name"] for s in skills} | {s["path"].name for s in skills}
    known |= non_skill_names(root)
    hits = {}
    for skill in skills:
        prose = strip_code_blocks(skill["body"])
        for pattern in INVOCATION_RES:
            for match in pattern.finditer(prose):
                candidate = match.group("name").lower()
                if candidate in known:
                    continue
                hits.setdefault(candidate, set()).add(skill["path"].name)
    return {k: sorted(v) for k, v in sorted(hits.items())}


def render(root, skills, divs, dangles):
    known_dangling = set(dangles)
    lines = []
    a = lines.append
    a("<!-- GENERATED FROM skills/ — DO NOT EDIT DIRECTLY.                            -->")
    a("<!-- This file is an artifact of the skills on disk, not of a spec: it carries -->")
    a("<!-- no GENERATED FROM SPEC header, which would name a source that did not     -->")
    a("<!-- produce it. Regenerate instead of editing.                                -->")
    a(f"<!-- Source root : {root}")
    a("<!-- Regenerate  : python3 skills/skill-router/build_router.py -->")
    a(f"<!-- On disk: {len(skills)} · divergences: {len(divs)} · dangling refs: {len(dangles)} -->")
    a("")
    a("# Routing table")
    a("")
    a("## 1. Index")
    a("")
    a("| # | name | phase | mode | precondition checked on disk |")
    a("|---|---|---|---|---|")
    for i, s in enumerate(sorted(skills, key=lambda s: (
            list(CLASSIFICATION).index(s["name"]) if s["name"] in CLASSIFICATION else 999,
            s["name"])), start=1):
        phase, mode = CLASSIFICATION.get(s["name"], ("unclassified", "inline"))
        pre = PRECONDITION.get(s["name"], "—")
        shown = f"**{mode}**" if mode in ("inline", "self") else mode
        a(f"| {i:02d} | {s['name']} | {phase} | {shown} | {pre} |")
    a("")
    a("Skills with `mode: inline` take the current conversation as input and MUST NOT")
    a("be delegated to a fresh-context subagent. `mode: self` is not a destination.")
    a("")
    a("## 2. Divergences — reported, never reconciled")
    a("")
    if divs:
        a("| kind | detail |")
        a("|---|---|")
        for kind, detail in divs:
            a(f"| {kind} | {detail} |")
    else:
        a("None: disk and `plugin.json` agree, and no shadow copies were found.")
    a("")
    a("## 3. Dangling references — never destinations")
    a("")
    if dangles:
        a("| name | referenced in | verdict |")
        a("|---|---|---|")
        for name, sources in dangles.items():
            a(f"| `{name}` | {', '.join(sources)} | not on disk → never a destination |")
    else:
        a("None: every skill name referenced in a body exists.")
    a("")
    a("## 4. Descriptions — verbatim and complete (the routing contract)")
    a("")
    a("Match is computed on these, and only among candidates admissible in the")
    a("current phase.")
    a("")
    for s in sorted(skills, key=lambda s: s["name"]):
        phase, mode = CLASSIFICATION.get(s["name"], ("unclassified", "inline"))
        a(f"### {s['name']} — phase {phase} — mode {mode}")
        a("")
        a(f"- path: `{s['path'].relative_to(root)}`")
        a("")
        for line in s["description"].splitlines():
            a(f"> {line}".rstrip())
        a("")
    assert known_dangling.isdisjoint({s["name"] for s in skills})
    return "\n".join(lines).rstrip("\n") + "\n"


def render_sequence(skills):
    """Render the human-readable workflow arc from the routing model."""
    by_phase = {}
    for skill in skills:
        phase, _mode = CLASSIFICATION.get(skill["name"], ("unclassified", "inline"))
        by_phase.setdefault(phase, []).append(skill["name"])
    for members in by_phase.values():
        members.sort()
    if "9-close" in by_phase:
        by_phase["9-close"].sort(key=lambda name: (name != "openspec-sync-specs", name))
    # The order is already encoded in the phase names (`0-setup`, `1-plan`, …),
    # so it is derived rather than restated. A hardcoded list was a second
    # source of truth for the same fact, and the two had already diverged: it
    # began at `1-plan`, so `0-setup` fell through to a catch-all that appended
    # leftovers to the tail — the arc ended up teaching that setup comes after
    # archiving.
    # `unclassified` is a legitimate state — a skill on disk that CLASSIFICATION
    # does not yet place — so it is declared in its own section rather than
    # dropped or guessed into the arc.
    NON_ARC = {"any", "maint", "—", "unclassified"}
    BOOTSTRAP = "0-setup"
    arc_candidates = [p for p in by_phase if p not in NON_ARC and p != BOOTSTRAP]

    # Fail loudly rather than silently misplacing a phase. Ordinal names order
    # themselves, so a new phase lands in its right position for free; a phase
    # that is neither ordinal nor explicitly non-arc cannot be positioned, and
    # guessing is what produced the defect this replaced.
    unplaceable = [p for p in arc_candidates if not re.match(r"^\d+-", p)]
    if unplaceable:
        die("cannot order phase(s) without an ordinal prefix: "
            + ", ".join(sorted(unplaceable))
            + " — name them `<n>-<label>` or classify them as `any`/`maint`")

    # Compare the ordinal as an integer, not as text: lexical sorting would put
    # `10-late` between `1-plan` and `2-intake`, reproducing the same class of
    # misleading arc while still looking sorted to a lexical test.
    arc_phases = sorted(arc_candidates,
                        key=lambda phase: (int(phase.split("-", 1)[0]), phase))

    main_arc = " → ".join(" / ".join(f"`{name}`" for name in by_phase.get(phase, []))
                          for phase in arc_phases if by_phase.get(phase)) or "n/a"
    bootstrap = " / ".join(f"`{name}`" for name in by_phase.get(BOOTSTRAP, []))
    lines = [
        "<!-- Generated section below is produced by skills/skill-router/build_router.py -->",
        "<!-- from the routing model. Regenerate it; do not edit it by hand.            -->",
        "<!-- Everything after END GENERATED SECTION is hand-authored and preserved.    -->",
        "<!-- Spec: openspec/specs/skill-router/spec.md                                 -->",
        "# Workflow sequence",
        "",
        "The sequence explains the expected arc; the router's probes on disk always win.",
        "",
        "## Bootstrap, before the arc (generated)",
        "",
        "- " + (bootstrap or "n/a") + " runs **once**, only when probe P0 trips because the "
        "enforcement scripts or schemas are missing. It is a precondition of the arc, not a step "
        "in it, and it is not revisited on later work.",
        "",
        "## Main arc (generated)",
        "",
        main_arc,
        "",
        "- The arc is **plan-first**: no work starts before its plan entry is approved.",
        "- In this repository's operator workflow, `agent-registry` precedes even the plan. "
        "It is **not a skill of this plugin**, so it cannot appear in a skeleton "
        "generated from the routing model. Its absence here is a boundary of the generator, not "
        "a statement that it is optional.",
        "",
        "## Transverse lanes (generated)",
        "",
        "- " + (" / ".join(f"`{name}`" for name in by_phase.get("any", [])) or "n/a") + " are available in every phase.",
        "",
        "## Orthogonal maintenance (generated)",
        "",
        "- " + (" / ".join(f"`{name}`" for name in by_phase.get("maint", [])) or "n/a") + " are maintenance work, not main-arc steps.",
        "",
        "## Unclassified (generated)",
        "",
        "- " + (" / ".join(f"`{name}`" for name in by_phase.get("unclassified", [])) or "n/a")
        + " have no declared phase yet, so the router cannot place them in the arc. Listed here "
        "rather than dropped: a skill missing from the sequence is invisible, and invisible is "
        "how a skill stays unreachable.",
        "",
        "## Choice nodes (generated)",
        "",
    ]
    # Two names and no way to choose between them is not a choice node, it is an
    # unanswered question. The distinguishing criterion is the precondition the
    # table already carries, so it is rendered rather than restated.
    for phase in arc_phases:
        members = by_phase.get(phase, [])
        if len(members) > 1 and phase != "9-close":
            missing_preconditions = [name for name in members if not PRECONDITION.get(name)]
            if missing_preconditions:
                die("choice node has no declared precondition for: "
                    + ", ".join(missing_preconditions))
            lines.append(f"- {phase}:")
            for name in members:
                lines.append(f"    - `{name}` — {PRECONDITION[name]}")
    if by_phase.get("9-close"):
        lines.append("- 9-close: `openspec-sync-specs` precedes `openspec-archive-change` when delta specs require sync.")
    lines += ["", "<!-- END GENERATED SECTION -->"]
    return "\n".join(lines) + "\n"


def manual_sequence_prose(path):
    """Preserve the human-authored tail outside the generated marker."""
    if not path.is_file():
        return "\n## Interpretation\n\nThe router selects from disk state and request intent, announces the next expected step, and stops for a new invocation.\n"
    marker = "<!-- END GENERATED SECTION -->"
    text = path.read_text(encoding="utf-8")
    return text.split(marker, 1)[1].lstrip("\n") if marker in text else ""


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--stdout", action="store_true",
                        help="print the table instead of writing it")
    parser.add_argument("--sequence-stdout", action="store_true",
                        help="print the generated sequence instead of writing it")
    parser.add_argument("--check", action="store_true",
                        help="exit non-zero if the written table is out of date")
    args = parser.parse_args()

    here = Path(__file__).resolve().parent
    default_root = here.parent.parent
    root = Path(os.environ.get("SKILL_SOURCE_ROOT", default_root)).expanduser().resolve()

    skills = scan(root)
    divs = divergences(root, skills)
    dangles = dangling(root, skills)
    table = render(root, skills, divs, dangles)
    sequence_out = here / "SEQUENCE.md"
    sequence = render_sequence(skills) + manual_sequence_prose(sequence_out)

    if args.stdout:
        sys.stdout.write(table)
        return 0
    if args.sequence_stdout:
        sys.stdout.write(sequence)
        return 0

    out = here / "routing-table.md"
    if args.check:
        current = out.read_text(encoding="utf-8") if out.is_file() else ""
        current_sequence = sequence_out.read_text(encoding="utf-8") if sequence_out.is_file() else ""
        if current != table or current_sequence != sequence:
            print("build_router: generated router artifacts are out of date — regenerate them",
                  file=sys.stderr)
            return 1
        print(f"build_router: router artifacts up to date ({len(skills)} skills)")
        return 0

    out.write_text(table, encoding="utf-8")
    sequence_out.write_text(sequence, encoding="utf-8")
    print(f"build_router: wrote {out.relative_to(root)} "
          f"({len(skills)} skills, {len(divs)} divergences, "
          f"{len(dangles)} dangling refs)")
    return 0


sys.exit(main())

skills

README.md

tile.json