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
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
#!/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()).tessl-plugin
rules
skills
handoff
handoff-skill
openspec-apply-change
openspec-archive-change
openspec-explore
openspec-propose
openspec-sync-specs
plan-judge
plan-mode
prompt-loop
requirement-gathering
spec-as-source-setup
templates
openspec-schema
spec-as-source
templates
spec-ci-sync
spec-loop
spec-rebuild
spec-verify
spec-writer
work-review