diff --git a/.github/workflows/plugin-validation.yml b/.github/workflows/plugin-validation.yml new file mode 100644 index 0000000..986d6be --- /dev/null +++ b/.github/workflows/plugin-validation.yml @@ -0,0 +1,23 @@ +name: Plugin validation + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + name: Validate Scribe plugin package + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate plugin manifests and draw rotation + run: python3 tools/validate_plugin.py diff --git a/docs/launch-evidence.md b/docs/launch-evidence.md new file mode 100644 index 0000000..a217bf0 --- /dev/null +++ b/docs/launch-evidence.md @@ -0,0 +1,42 @@ +# Launch evidence + +Scribe is a public Claude Code plugin. Before a release, the repo needs evidence that the plugin package is internally consistent and the anti-pattern draw rule works without the Claude runtime. + +## Current automated gate + +Run from the repo root: + +```bash +python3 tools/validate_plugin.py +``` + +The validator checks: + +- `.claude-plugin/plugin.json` parses and points at `https://github.com/raymondware/scribe` +- `.claude-plugin/marketplace.json` exposes exactly one `scribe` plugin entry aligned with `plugin.json` +- every agent file has `name`, `model`, and `description` frontmatter +- every command file has `description` frontmatter +- every anti-pattern manifest entry has a unique kebab-case id, required metadata, an existing source file, and a matching markdown heading anchor +- the five persona files are present and listed in the persona README +- `tools/draw_simulator.py` can run twice against a temporary audit log and produce different subsets + +## CI + +`.github/workflows/plugin-validation.yml` runs the same validator on pull requests and pushes to `main`. + +## Local smoke transcript + +Expected successful output: + +```text +Scribe plugin validation passed +``` + +This is package and deterministic-selector evidence only. It is not a Claude Code marketplace install transcript, release tag, demo recording, paid usage proof, or public launch approval. + +## Remaining launch evidence gaps + +- Run an actual Claude Code plugin install smoke after Ray approves a release candidate. +- Record a `/scribe:draw` command transcript from a real plugin install. +- Create a release tag only with explicit approval. +- Keep public launch, external posts, marketplace changes, and branch protection changes approval-gated. diff --git a/tools/validate_plugin.py b/tools/validate_plugin.py new file mode 100644 index 0000000..c797b88 --- /dev/null +++ b/tools/validate_plugin.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Validate Scribe plugin packaging and deterministic draw behavior.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NoReturn + +ROOT = Path(__file__).resolve().parents[1] + + +def fail(message: str) -> NoReturn: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def read_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: # pragma: no cover - diagnostic path + fail(f"{path.relative_to(ROOT)} is not valid JSON: {exc}") + + +def slugify_heading(text: str) -> str: + text = text.strip().lower() + text = re.sub(r"[`*_]", "", text) + text = re.sub(r"[^a-z0-9\s-]", "", text) + text = re.sub(r"\s+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") + + +def validate_plugin_manifests() -> None: + plugin = read_json(ROOT / ".claude-plugin" / "plugin.json") + marketplace = read_json(ROOT / ".claude-plugin" / "marketplace.json") + + if plugin.get("name") != "scribe": + fail("plugin.json name must be scribe") + if plugin.get("version") != "1.0.0": + fail("plugin.json version must stay aligned with release docs") + if plugin.get("license") != "MIT": + fail("plugin.json license must be MIT") + if plugin.get("repository") != "https://github.com/raymondware/scribe": + fail("plugin.json repository URL is not the public repo") + + plugins = marketplace.get("plugins") + if not isinstance(plugins, list) or len(plugins) != 1: + fail("marketplace.json must expose exactly one plugin") + entry = plugins[0] + for key in ["name", "version", "description", "category", "source"]: + if key not in entry: + fail(f"marketplace plugin entry missing {key}") + if entry["name"] != plugin["name"] or entry["version"] != plugin["version"]: + fail("marketplace plugin name/version must match plugin.json") + if entry["source"] != "./": + fail("marketplace plugin source must remain ./ for repo-root plugin install") + + +def frontmatter(path: Path) -> dict[str, str]: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0] != "---": + fail(f"{path.relative_to(ROOT)} missing YAML frontmatter fence") + end = None + for i, line in enumerate(lines[1:], start=1): + if line == "---": + end = i + break + if end is None: + fail(f"{path.relative_to(ROOT)} missing closing frontmatter fence") + data: dict[str, str] = {} + for line in lines[1:end]: + if not line.strip() or ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip()] = value.strip().strip('"') + return data + + +def validate_agents_and_commands() -> None: + agent_dir = ROOT / "agents" + command_dir = ROOT / "commands" + agents = sorted(agent_dir.glob("*.md")) + commands = sorted(command_dir.glob("*.md")) + if len(agents) < 10: + fail("expected at least 10 Scribe agents") + if len(commands) < 8: + fail("expected at least 8 Scribe commands") + + names: set[str] = set() + for path in agents: + fm = frontmatter(path) + for key in ["name", "model", "description"]: + if not fm.get(key): + fail(f"{path.relative_to(ROOT)} missing frontmatter field {key}") + if fm["name"] in names: + fail(f"duplicate agent name {fm['name']}") + names.add(fm["name"]) + + for path in commands: + fm = frontmatter(path) + if not fm.get("description"): + fail(f"{path.relative_to(ROOT)} missing command description") + + +def validate_antipattern_manifest() -> None: + manifest = read_json(ROOT / "anti-patterns" / "manifest.json") + patterns = manifest.get("patterns") + if not isinstance(patterns, list) or len(patterns) < 20: + fail("anti-pattern manifest must include the public pattern catalog") + + seen: set[str] = set() + for pattern in patterns: + for key in ["id", "title", "categories", "severity_default", "source_file", "anchor", "personas", "site_signals"]: + if key not in pattern: + fail(f"pattern entry missing {key}: {pattern}") + pid = pattern["id"] + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", pid): + fail(f"pattern id is not kebab-case: {pid}") + if pid in seen: + fail(f"duplicate pattern id {pid}") + seen.add(pid) + source = ROOT / pattern["source_file"] + if not source.exists(): + fail(f"pattern {pid} source file missing: {pattern['source_file']}") + headings = [] + for line in source.read_text(encoding="utf-8").splitlines(): + if line.startswith("#"): + headings.append(slugify_heading(line.lstrip("#"))) + anchor = pattern["anchor"] + normalized_anchor = re.sub(r"-+", "-", anchor) + normalized_headings = {re.sub(r"-+", "-", heading) for heading in headings} + if anchor not in headings and normalized_anchor not in normalized_headings: + fail(f"pattern {pid} anchor {anchor} not found in {pattern['source_file']}") + + +def validate_personas() -> None: + persona_dir = ROOT / "skills" / "scribe" / "references" / "personas" + expected = { + "literary-fiction.md", + "popular-nonfiction.md", + "technical-writing.md", + "memoir.md", + "business-book.md", + } + actual = {p.name for p in persona_dir.glob("*.md") if p.name != "README.md"} + if actual != expected: + fail(f"persona set mismatch: {sorted(actual)}") + readme = (persona_dir / "README.md").read_text(encoding="utf-8") + for filename in sorted(expected): + persona_id = filename.removesuffix(".md") + if persona_id not in readme: + fail(f"personas README does not mention {persona_id}") + + +def validate_draw_rotation() -> None: + simulator = ROOT / "tools" / "draw_simulator.py" + with tempfile.TemporaryDirectory(prefix="scribe-validate-") as tmp: + state = Path(tmp) / "draws.jsonl" + base = [ + sys.executable, + str(simulator), + "business book chapter on why most pivots fail", + "--persona", + "business-book", + "--command", + "write", + "--state-file", + str(state), + ] + first = subprocess.run(base + ["--run-id", "scribe-ci-20260725-0001"], cwd=ROOT, text=True, capture_output=True) + if first.returncode != 0: + fail(f"first draw simulator run failed: {first.stderr or first.stdout}") + second = subprocess.run(base + ["--run-id", "scribe-ci-20260725-0002"], cwd=ROOT, text=True, capture_output=True) + if second.returncode != 0: + fail(f"second draw simulator run failed: {second.stderr or second.stdout}") + lines = [json.loads(line) for line in state.read_text(encoding="utf-8").splitlines() if line.strip()] + if len(lines) != 2: + fail("draw simulator should append exactly two audit lines") + if set(lines[0]["subset"]) == set(lines[1]["subset"]): + fail("draw simulator reused the same subset twice") + if not 8 <= lines[0]["subset_size"] <= 14 or not 8 <= lines[1]["subset_size"] <= 14: + fail("draw simulator subset size outside expected range") + + +def main() -> int: + validate_plugin_manifests() + validate_agents_and_commands() + validate_antipattern_manifest() + validate_personas() + validate_draw_rotation() + print("Scribe plugin validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())