Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/plugin-validation.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions docs/launch-evidence.md
Original file line number Diff line number Diff line change
@@ -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.
201 changes: 201 additions & 0 deletions tools/validate_plugin.py
Original file line number Diff line number Diff line change
@@ -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())
Loading